diff --git a/README.md b/README.md
index 5cf2bd7..55db649 100644
--- a/README.md
+++ b/README.md
@@ -54,6 +54,7 @@ Just as Chucker intercepts and displays HTTP traffic, Dari captures and visualiz
- Message list UI with search, filter by handler name, and **tag-based filtering**
- Detail view with Overview / Request / Response tabs
- JSON pretty-printing for request and response payloads
+- Optional Protocol Buffers inspection through an app-provided decoder
- Export messages as text or JSON
- **Shake-to-open** — shake device to launch Dari UI (with haptic feedback)
- **Dark mode** — System / Light / Dark theme toggle
@@ -133,6 +134,50 @@ interceptor?.onWebToAppRequest(handlerName, null, data)
When `requestId` is `null`, the message is treated as standalone and won't be matched with a response.
+#### 5. Protocol Buffers (optional)
+
+The existing `String`/JSON methods remain unchanged. To inspect protobuf payloads, create an interceptor with a decoder supplied by your app:
+
+```kotlin
+import com.easyhooon.dari.interceptor.PayloadPart.REQUEST
+import com.easyhooon.dari.interceptor.PayloadPart.RESPONSE
+import com.easyhooon.dari.interceptor.ProtobufPayloadDecoder
+
+val protobufInterceptor = Dari.createInterceptor(
+ tag = "OrderBridge",
+ protobufDecoder = ProtobufPayloadDecoder { payload, context ->
+ when (context.handlerName to context.part) {
+ "createOrder" to REQUEST ->
+ JsonFormat.printer().print(CreateOrderRequest.parseFrom(payload))
+ "createOrder" to RESPONSE ->
+ JsonFormat.printer().print(CreateOrderResponse.parseFrom(payload))
+ else -> null
+ }
+ },
+)
+
+protobufInterceptor?.onWebToAppProtobufRequest(
+ handlerName = "createOrder",
+ requestId = requestId,
+ requestData = protobufBytes,
+)
+```
+
+Dari does not depend on a protobuf runtime. The consuming app owns generated message types and decoding. Decoding runs synchronously on the interceptor caller's thread, so decoder work should stay bounded. Base64 is only needed when the app's bridge is string-only; an `ArrayBuffer` bridge can pass the bytes directly.
+
+The REQUEST and RESPONSE tabs provide `Decoded` and `Raw` views. The raw view retains only the first 4 KB of each protobuf payload and displays that preview as Hex and Base64; the original byte size and truncation state remain available. Base64 in this view is a display encoding generated by Dari, not evidence that the bridge transported the payload as Base64.
+
+
+
+
Protobuf Decoded
+
Protobuf Raw Bytes
+
+
+
+
+
+
+
### Custom Configuration
You can customize Dari by calling `init` with a config before auto-initialization occurs, or in your `Application.onCreate()`:
@@ -198,6 +243,7 @@ keep the same API surface without storing or displaying bridge messages.
|--------|-------------|
| `init(context, config)` | Initialize with custom configuration |
| `createInterceptor(tag?)` | Create a `DariInterceptor` with an optional tag (returns `null` in noop) |
+| `createInterceptor(tag?, protobufDecoder)` | Create a protobuf-capable interceptor while retaining all string/JSON methods |
| `setShakeToOpenEnabled(enabled)` | Enable/disable shake-to-open at runtime (persisted) |
| `setDarkMode(value)` | Override dark mode: `true` / `false` / `null` (system default). Persisted |
| `showNotification()` | Show the notification (e.g., after permission grant) |
@@ -212,6 +258,8 @@ keep the same API surface without storing or displaying bridge messages.
| `onAppToWebRequest()` | Log an App-to-Web message. `requestId` is optional for fire-and-forget messages. |
| `onAppToWebResponse()` | Log the response to an App-to-Web message. Skipped if `requestId` is null. |
+`ProtobufDariInterceptor` extends this interface with matching `on*ProtobufRequest()` and `on*ProtobufResponse()` methods that accept `ByteArray` payloads.
+
```kotlin
/**
* Interface for intercepting WebView bridge communication.
diff --git a/dari-core/src/main/kotlin/com/easyhooon/dari/DariConfig.kt b/dari-core/src/main/kotlin/com/easyhooon/dari/DariConfig.kt
index 9c63e1e..2073fb6 100644
--- a/dari-core/src/main/kotlin/com/easyhooon/dari/DariConfig.kt
+++ b/dari-core/src/main/kotlin/com/easyhooon/dari/DariConfig.kt
@@ -11,7 +11,7 @@ data class DariConfig(
val maxEntries: Int = 500,
/** Whether to show the status notification */
val showNotification: Boolean = true,
- /** Maximum character length for request/response body data. Bodies exceeding this limit are truncated. */
+ /** Maximum character length for text or decoded protobuf display data before truncation. */
val maxContentLength: Int = DEFAULT_MAX_CONTENT_LENGTH,
/** Whether to open DariActivity when the device is shaken */
val shakeToOpen: Boolean = false,
diff --git a/dari-core/src/main/kotlin/com/easyhooon/dari/MessageEntry.kt b/dari-core/src/main/kotlin/com/easyhooon/dari/MessageEntry.kt
index 6a1a406..fc49e3c 100644
--- a/dari-core/src/main/kotlin/com/easyhooon/dari/MessageEntry.kt
+++ b/dari-core/src/main/kotlin/com/easyhooon/dari/MessageEntry.kt
@@ -21,8 +21,41 @@ data class MessageEntry(
val status: MessageStatus = MessageStatus.IN_PROGRESS,
val requestTimestamp: Long = System.currentTimeMillis(),
val responseTimestamp: Long? = null,
+ val requestPayloadMetadata: MessagePayloadMetadata? = null,
+ val responsePayloadMetadata: MessagePayloadMetadata? = null,
) {
+ /** Preserves the pre-protobuf constructor for binary compatibility. */
+ constructor(
+ id: Long,
+ requestId: String?,
+ handlerName: String,
+ direction: MessageDirection,
+ tag: String?,
+ requestData: String?,
+ responseData: String?,
+ requestDataTruncated: Boolean,
+ responseDataTruncated: Boolean,
+ status: MessageStatus,
+ requestTimestamp: Long,
+ responseTimestamp: Long?,
+ ) : this(
+ id = id,
+ requestId = requestId,
+ handlerName = handlerName,
+ direction = direction,
+ tag = tag,
+ requestData = requestData,
+ responseData = responseData,
+ requestDataTruncated = requestDataTruncated,
+ responseDataTruncated = responseDataTruncated,
+ status = status,
+ requestTimestamp = requestTimestamp,
+ responseTimestamp = responseTimestamp,
+ requestPayloadMetadata = null,
+ responsePayloadMetadata = null,
+ )
+
/**
* Secondary constructor preserving the original parameter order for
* backward compatibility with external positional callers (e.g., Java).
@@ -53,13 +86,15 @@ data class MessageEntry(
val durationMs: Long?
get() = responseTimestamp?.let { it - requestTimestamp }
- /** Total byte size of request + response data */
+ val requestSizeBytes: Int
+ get() = requestPayloadMetadata?.originalSizeBytes ?: requestData.utf8Size()
+
+ val responseSizeBytes: Int
+ get() = responsePayloadMetadata?.originalSizeBytes ?: responseData.utf8Size()
+
+ /** Total byte size of the original request and response payloads. */
val totalSizeBytes: Int
- get() {
- val requestSize = requestData?.toByteArray(Charsets.UTF_8)?.size ?: 0
- val responseSize = responseData?.toByteArray(Charsets.UTF_8)?.size ?: 0
- return requestSize + responseSize
- }
+ get() = requestSizeBytes + responseSizeBytes
companion object {
/**
@@ -73,3 +108,5 @@ data class MessageEntry(
}
}
}
+
+private fun String?.utf8Size(): Int = this?.toByteArray(Charsets.UTF_8)?.size ?: 0
diff --git a/dari-core/src/main/kotlin/com/easyhooon/dari/MessagePayloadMetadata.kt b/dari-core/src/main/kotlin/com/easyhooon/dari/MessagePayloadMetadata.kt
new file mode 100644
index 0000000..746eefd
--- /dev/null
+++ b/dari-core/src/main/kotlin/com/easyhooon/dari/MessagePayloadMetadata.kt
@@ -0,0 +1,27 @@
+package com.easyhooon.dari
+
+/** Metadata retained when a binary payload is rendered as text for inspection. */
+data class MessagePayloadMetadata(
+ val contentType: PayloadContentType,
+ val originalSizeBytes: Int,
+ val decodeStatus: PayloadDecodeStatus,
+ val rawPreview: RawPayloadPreview? = null,
+)
+
+/** Bounded binary preview retained for raw payload inspection. */
+data class RawPayloadPreview(
+ val base64: String,
+ val previewSizeBytes: Int,
+ val truncated: Boolean,
+)
+
+enum class PayloadContentType {
+ PROTOBUF,
+}
+
+/** Result of converting a binary payload into inspectable display text. */
+enum class PayloadDecodeStatus {
+ DECODED,
+ DECODER_UNAVAILABLE,
+ FAILED,
+}
diff --git a/dari-core/src/main/kotlin/com/easyhooon/dari/interceptor/ProtobufDariInterceptor.kt b/dari-core/src/main/kotlin/com/easyhooon/dari/interceptor/ProtobufDariInterceptor.kt
new file mode 100644
index 0000000..19d3eeb
--- /dev/null
+++ b/dari-core/src/main/kotlin/com/easyhooon/dari/interceptor/ProtobufDariInterceptor.kt
@@ -0,0 +1,54 @@
+package com.easyhooon.dari.interceptor
+
+import com.easyhooon.dari.MessageDirection
+
+/** Identifies which side of a bridge exchange a protobuf payload belongs to. */
+enum class PayloadPart {
+ REQUEST,
+ RESPONSE,
+}
+
+data class ProtobufDecodeContext(
+ val handlerName: String,
+ val direction: MessageDirection,
+ val part: PayloadPart,
+)
+
+/**
+ * Converts protobuf bytes into display text without coupling Dari to a protobuf runtime.
+ * Return `null` when the decoder does not recognize the supplied context.
+ * Decoding runs synchronously on the interceptor caller's thread.
+ */
+fun interface ProtobufPayloadDecoder {
+ fun decode(payload: ByteArray, context: ProtobufDecodeContext): String?
+}
+
+/** Optional protobuf extension of the existing string-based [DariInterceptor]. */
+interface ProtobufDariInterceptor : DariInterceptor {
+ fun onWebToAppProtobufRequest(
+ handlerName: String,
+ requestId: String?,
+ requestData: ByteArray,
+ fireAndForget: Boolean? = null,
+ )
+
+ fun onWebToAppProtobufResponse(
+ handlerName: String,
+ requestId: String?,
+ responseData: ByteArray,
+ isSuccess: Boolean,
+ )
+
+ fun onAppToWebProtobufRequest(
+ handlerName: String,
+ requestId: String?,
+ requestData: ByteArray,
+ fireAndForget: Boolean? = null,
+ )
+
+ fun onAppToWebProtobufResponse(
+ requestId: String?,
+ isSuccess: Boolean,
+ responseData: ByteArray,
+ )
+}
diff --git a/dari-noop/src/main/kotlin/com/easyhooon/dari/Dari.kt b/dari-noop/src/main/kotlin/com/easyhooon/dari/Dari.kt
index 9cb43ba..1590f8e 100644
--- a/dari-noop/src/main/kotlin/com/easyhooon/dari/Dari.kt
+++ b/dari-noop/src/main/kotlin/com/easyhooon/dari/Dari.kt
@@ -2,6 +2,8 @@ package com.easyhooon.dari
import android.content.Context
import com.easyhooon.dari.interceptor.DariInterceptor
+import com.easyhooon.dari.interceptor.ProtobufDariInterceptor
+import com.easyhooon.dari.interceptor.ProtobufPayloadDecoder
/**
* Noop implementation - does not create an interceptor in release builds.
@@ -14,6 +16,12 @@ object Dari {
@Suppress("UNUSED_PARAMETER", "FunctionOnlyReturningConstant")
fun createInterceptor(tag: String? = null): DariInterceptor? = null
+ @Suppress("UNUSED_PARAMETER", "FunctionOnlyReturningConstant")
+ fun createInterceptor(
+ tag: String? = null,
+ protobufDecoder: ProtobufPayloadDecoder,
+ ): ProtobufDariInterceptor? = null
+
@Suppress("UNUSED_PARAMETER")
fun setShakeToOpenEnabled(enabled: Boolean) = Unit
diff --git a/dari/src/main/kotlin/com/easyhooon/dari/Dari.kt b/dari/src/main/kotlin/com/easyhooon/dari/Dari.kt
index 3a9f13d..29e612d 100644
--- a/dari/src/main/kotlin/com/easyhooon/dari/Dari.kt
+++ b/dari/src/main/kotlin/com/easyhooon/dari/Dari.kt
@@ -15,6 +15,8 @@ import com.easyhooon.dari.data.local.DariDatabase
import java.io.File
import com.easyhooon.dari.interceptor.DariInterceptor
import com.easyhooon.dari.interceptor.DefaultDariInterceptor
+import com.easyhooon.dari.interceptor.ProtobufDariInterceptor
+import com.easyhooon.dari.interceptor.ProtobufPayloadDecoder
import com.easyhooon.dari.notification.DariNotification
import com.easyhooon.dari.shake.DariShakeManager
import com.easyhooon.dari.ui.DariActivity
@@ -119,6 +121,15 @@ object Dari {
@Suppress("RedundantNullableReturnType") // Returns null in noop module
fun createInterceptor(tag: String? = null): DariInterceptor? = DefaultDariInterceptor(tag)
+ /**
+ * Creates an interceptor that supports both the existing string payloads and protobuf bytes.
+ * The decoder is supplied by the consuming app, so Dari does not impose a protobuf runtime.
+ */
+ fun createInterceptor(
+ tag: String? = null,
+ protobufDecoder: ProtobufPayloadDecoder,
+ ): ProtobufDariInterceptor? = DefaultDariInterceptor(tag, protobufDecoder)
+
/**
* Adds a new message to the notification.
*/
diff --git a/dari/src/main/kotlin/com/easyhooon/dari/RawPayloadFormatter.kt b/dari/src/main/kotlin/com/easyhooon/dari/RawPayloadFormatter.kt
new file mode 100644
index 0000000..d905880
--- /dev/null
+++ b/dari/src/main/kotlin/com/easyhooon/dari/RawPayloadFormatter.kt
@@ -0,0 +1,20 @@
+package com.easyhooon.dari
+
+import java.util.Base64
+
+internal object RawPayloadFormatter {
+ fun formatHex(preview: RawPayloadPreview): String {
+ val bytes = runCatching {
+ Base64.getDecoder().decode(preview.base64)
+ }.getOrElse {
+ return "(raw preview unavailable)"
+ }
+ if (bytes.isEmpty()) return "(empty)"
+
+ return bytes.asIterable()
+ .chunked(16)
+ .joinToString("\n") { line ->
+ line.joinToString(" ") { byte -> "%02X".format(byte.toInt() and 0xFF) }
+ }
+ }
+}
diff --git a/dari/src/main/kotlin/com/easyhooon/dari/data/MessageRepository.kt b/dari/src/main/kotlin/com/easyhooon/dari/data/MessageRepository.kt
index 06da3f4..67a7718 100644
--- a/dari/src/main/kotlin/com/easyhooon/dari/data/MessageRepository.kt
+++ b/dari/src/main/kotlin/com/easyhooon/dari/data/MessageRepository.kt
@@ -119,6 +119,12 @@ class MessageRepository internal constructor(
tag = tag,
responseData = entry.responseData,
responseDataTruncated = entry.responseDataTruncated,
+ responseContentType = entry.responsePayloadMetadata?.contentType,
+ responseOriginalSizeBytes = entry.responsePayloadMetadata?.originalSizeBytes,
+ responseDecodeStatus = entry.responsePayloadMetadata?.decodeStatus,
+ responseRawPreviewBase64 = entry.responsePayloadMetadata?.rawPreview?.base64,
+ responseRawPreviewSizeBytes = entry.responsePayloadMetadata?.rawPreview?.previewSizeBytes,
+ responseRawPreviewTruncated = entry.responsePayloadMetadata?.rawPreview?.truncated,
status = entry.status,
responseTimestamp = entry.responseTimestamp,
)
diff --git a/dari/src/main/kotlin/com/easyhooon/dari/data/local/Converters.kt b/dari/src/main/kotlin/com/easyhooon/dari/data/local/Converters.kt
index fcf7f2e..d881bbd 100644
--- a/dari/src/main/kotlin/com/easyhooon/dari/data/local/Converters.kt
+++ b/dari/src/main/kotlin/com/easyhooon/dari/data/local/Converters.kt
@@ -3,6 +3,8 @@ package com.easyhooon.dari.data.local
import androidx.room.TypeConverter
import com.easyhooon.dari.MessageDirection
import com.easyhooon.dari.MessageStatus
+import com.easyhooon.dari.PayloadContentType
+import com.easyhooon.dari.PayloadDecodeStatus
internal class Converters {
@TypeConverter
@@ -16,4 +18,16 @@ internal class Converters {
@TypeConverter
fun toStatus(value: String): MessageStatus = MessageStatus.valueOf(value)
+
+ @TypeConverter
+ fun fromPayloadContentType(contentType: PayloadContentType): String = contentType.name
+
+ @TypeConverter
+ fun toPayloadContentType(value: String): PayloadContentType = PayloadContentType.valueOf(value)
+
+ @TypeConverter
+ fun fromPayloadDecodeStatus(status: PayloadDecodeStatus): String = status.name
+
+ @TypeConverter
+ fun toPayloadDecodeStatus(value: String): PayloadDecodeStatus = PayloadDecodeStatus.valueOf(value)
}
diff --git a/dari/src/main/kotlin/com/easyhooon/dari/data/local/DariDatabase.kt b/dari/src/main/kotlin/com/easyhooon/dari/data/local/DariDatabase.kt
index 9ad1a3f..cdb98d2 100644
--- a/dari/src/main/kotlin/com/easyhooon/dari/data/local/DariDatabase.kt
+++ b/dari/src/main/kotlin/com/easyhooon/dari/data/local/DariDatabase.kt
@@ -5,10 +5,12 @@ import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
+import androidx.room.migration.Migration
+import androidx.sqlite.db.SupportSQLiteDatabase
@Database(
entities = [MessageEntity::class],
- version = 3,
+ version = 5,
exportSchema = false,
)
@TypeConverters(Converters::class)
@@ -21,8 +23,31 @@ internal abstract class DariDatabase : RoomDatabase() {
fun create(context: Context): DariDatabase {
return Room.databaseBuilder(context, DariDatabase::class.java, DB_NAME)
+ .addMigrations(MIGRATION_3_4, MIGRATION_4_5)
.fallbackToDestructiveMigration(dropAllTables = true)
.build()
}
+
+ private val MIGRATION_3_4 = object : Migration(3, 4) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ db.execSQL("ALTER TABLE `messages` ADD COLUMN `requestContentType` TEXT")
+ db.execSQL("ALTER TABLE `messages` ADD COLUMN `requestOriginalSizeBytes` INTEGER")
+ db.execSQL("ALTER TABLE `messages` ADD COLUMN `requestDecodeStatus` TEXT")
+ db.execSQL("ALTER TABLE `messages` ADD COLUMN `responseContentType` TEXT")
+ db.execSQL("ALTER TABLE `messages` ADD COLUMN `responseOriginalSizeBytes` INTEGER")
+ db.execSQL("ALTER TABLE `messages` ADD COLUMN `responseDecodeStatus` TEXT")
+ }
+ }
+
+ private val MIGRATION_4_5 = object : Migration(4, 5) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ db.execSQL("ALTER TABLE `messages` ADD COLUMN `requestRawPreviewBase64` TEXT")
+ db.execSQL("ALTER TABLE `messages` ADD COLUMN `requestRawPreviewSizeBytes` INTEGER")
+ db.execSQL("ALTER TABLE `messages` ADD COLUMN `requestRawPreviewTruncated` INTEGER")
+ db.execSQL("ALTER TABLE `messages` ADD COLUMN `responseRawPreviewBase64` TEXT")
+ db.execSQL("ALTER TABLE `messages` ADD COLUMN `responseRawPreviewSizeBytes` INTEGER")
+ db.execSQL("ALTER TABLE `messages` ADD COLUMN `responseRawPreviewTruncated` INTEGER")
+ }
+ }
}
}
diff --git a/dari/src/main/kotlin/com/easyhooon/dari/data/local/MessageDao.kt b/dari/src/main/kotlin/com/easyhooon/dari/data/local/MessageDao.kt
index 3084c12..779a56a 100644
--- a/dari/src/main/kotlin/com/easyhooon/dari/data/local/MessageDao.kt
+++ b/dari/src/main/kotlin/com/easyhooon/dari/data/local/MessageDao.kt
@@ -3,6 +3,8 @@ package com.easyhooon.dari.data.local
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query
+import com.easyhooon.dari.PayloadContentType
+import com.easyhooon.dari.PayloadDecodeStatus
import com.easyhooon.dari.MessageStatus
@Dao
@@ -15,13 +17,19 @@ internal interface MessageDao {
suspend fun insert(entity: MessageEntity): Long
@Query(
- "UPDATE messages SET responseData = :responseData, responseDataTruncated = :responseDataTruncated, status = :status, responseTimestamp = :responseTimestamp WHERE requestId = :requestId AND (tag = :tag OR (:tag IS NULL AND tag IS NULL))"
+ "UPDATE messages SET responseData = :responseData, responseDataTruncated = :responseDataTruncated, responseContentType = :responseContentType, responseOriginalSizeBytes = :responseOriginalSizeBytes, responseDecodeStatus = :responseDecodeStatus, responseRawPreviewBase64 = :responseRawPreviewBase64, responseRawPreviewSizeBytes = :responseRawPreviewSizeBytes, responseRawPreviewTruncated = :responseRawPreviewTruncated, status = :status, responseTimestamp = :responseTimestamp WHERE requestId = :requestId AND (tag = :tag OR (:tag IS NULL AND tag IS NULL))"
)
suspend fun updateByRequestId(
requestId: String,
tag: String?,
responseData: String?,
responseDataTruncated: Boolean,
+ responseContentType: PayloadContentType?,
+ responseOriginalSizeBytes: Int?,
+ responseDecodeStatus: PayloadDecodeStatus?,
+ responseRawPreviewBase64: String?,
+ responseRawPreviewSizeBytes: Int?,
+ responseRawPreviewTruncated: Boolean?,
status: MessageStatus,
responseTimestamp: Long?,
)
diff --git a/dari/src/main/kotlin/com/easyhooon/dari/data/local/MessageEntity.kt b/dari/src/main/kotlin/com/easyhooon/dari/data/local/MessageEntity.kt
index f814635..757c268 100644
--- a/dari/src/main/kotlin/com/easyhooon/dari/data/local/MessageEntity.kt
+++ b/dari/src/main/kotlin/com/easyhooon/dari/data/local/MessageEntity.kt
@@ -4,7 +4,11 @@ import androidx.room.Entity
import androidx.room.PrimaryKey
import com.easyhooon.dari.MessageDirection
import com.easyhooon.dari.MessageEntry
+import com.easyhooon.dari.MessagePayloadMetadata
import com.easyhooon.dari.MessageStatus
+import com.easyhooon.dari.PayloadContentType
+import com.easyhooon.dari.PayloadDecodeStatus
+import com.easyhooon.dari.RawPayloadPreview
@Entity(tableName = "messages")
internal data class MessageEntity(
@@ -18,6 +22,18 @@ internal data class MessageEntity(
val responseData: String? = null,
val requestDataTruncated: Boolean = false,
val responseDataTruncated: Boolean = false,
+ val requestContentType: PayloadContentType? = null,
+ val requestOriginalSizeBytes: Int? = null,
+ val requestDecodeStatus: PayloadDecodeStatus? = null,
+ val requestRawPreviewBase64: String? = null,
+ val requestRawPreviewSizeBytes: Int? = null,
+ val requestRawPreviewTruncated: Boolean? = null,
+ val responseContentType: PayloadContentType? = null,
+ val responseOriginalSizeBytes: Int? = null,
+ val responseDecodeStatus: PayloadDecodeStatus? = null,
+ val responseRawPreviewBase64: String? = null,
+ val responseRawPreviewSizeBytes: Int? = null,
+ val responseRawPreviewTruncated: Boolean? = null,
val status: MessageStatus = MessageStatus.IN_PROGRESS,
val requestTimestamp: Long = System.currentTimeMillis(),
val responseTimestamp: Long? = null,
@@ -32,6 +48,18 @@ internal fun MessageEntry.toEntity(): MessageEntity = MessageEntity(
responseData = responseData,
requestDataTruncated = requestDataTruncated,
responseDataTruncated = responseDataTruncated,
+ requestContentType = requestPayloadMetadata?.contentType,
+ requestOriginalSizeBytes = requestPayloadMetadata?.originalSizeBytes,
+ requestDecodeStatus = requestPayloadMetadata?.decodeStatus,
+ requestRawPreviewBase64 = requestPayloadMetadata?.rawPreview?.base64,
+ requestRawPreviewSizeBytes = requestPayloadMetadata?.rawPreview?.previewSizeBytes,
+ requestRawPreviewTruncated = requestPayloadMetadata?.rawPreview?.truncated,
+ responseContentType = responsePayloadMetadata?.contentType,
+ responseOriginalSizeBytes = responsePayloadMetadata?.originalSizeBytes,
+ responseDecodeStatus = responsePayloadMetadata?.decodeStatus,
+ responseRawPreviewBase64 = responsePayloadMetadata?.rawPreview?.base64,
+ responseRawPreviewSizeBytes = responsePayloadMetadata?.rawPreview?.previewSizeBytes,
+ responseRawPreviewTruncated = responsePayloadMetadata?.rawPreview?.truncated,
status = status,
requestTimestamp = requestTimestamp,
responseTimestamp = responseTimestamp,
@@ -47,7 +75,42 @@ internal fun MessageEntity.toMessageEntry(): MessageEntry = MessageEntry(
responseData = responseData,
requestDataTruncated = requestDataTruncated,
responseDataTruncated = responseDataTruncated,
+ requestPayloadMetadata = payloadMetadata(
+ contentType = requestContentType,
+ originalSizeBytes = requestOriginalSizeBytes,
+ decodeStatus = requestDecodeStatus,
+ rawPreviewBase64 = requestRawPreviewBase64,
+ rawPreviewSizeBytes = requestRawPreviewSizeBytes,
+ rawPreviewTruncated = requestRawPreviewTruncated,
+ ),
+ responsePayloadMetadata = payloadMetadata(
+ contentType = responseContentType,
+ originalSizeBytes = responseOriginalSizeBytes,
+ decodeStatus = responseDecodeStatus,
+ rawPreviewBase64 = responseRawPreviewBase64,
+ rawPreviewSizeBytes = responseRawPreviewSizeBytes,
+ rawPreviewTruncated = responseRawPreviewTruncated,
+ ),
status = status,
requestTimestamp = requestTimestamp,
responseTimestamp = responseTimestamp,
)
+
+private fun payloadMetadata(
+ contentType: PayloadContentType?,
+ originalSizeBytes: Int?,
+ decodeStatus: PayloadDecodeStatus?,
+ rawPreviewBase64: String?,
+ rawPreviewSizeBytes: Int?,
+ rawPreviewTruncated: Boolean?,
+): MessagePayloadMetadata? {
+ if (contentType == null || originalSizeBytes == null || decodeStatus == null) return null
+ val rawPreview = if (
+ rawPreviewBase64 != null && rawPreviewSizeBytes != null && rawPreviewTruncated != null
+ ) {
+ RawPayloadPreview(rawPreviewBase64, rawPreviewSizeBytes, rawPreviewTruncated)
+ } else {
+ null
+ }
+ return MessagePayloadMetadata(contentType, originalSizeBytes, decodeStatus, rawPreview)
+}
diff --git a/dari/src/main/kotlin/com/easyhooon/dari/export/DariExporter.kt b/dari/src/main/kotlin/com/easyhooon/dari/export/DariExporter.kt
index 7dc5b00..a0467c2 100644
--- a/dari/src/main/kotlin/com/easyhooon/dari/export/DariExporter.kt
+++ b/dari/src/main/kotlin/com/easyhooon/dari/export/DariExporter.kt
@@ -7,6 +7,8 @@ import android.util.Log
import androidx.core.content.FileProvider
import com.easyhooon.dari.MessageDirection
import com.easyhooon.dari.MessageEntry
+import com.easyhooon.dari.MessagePayloadMetadata
+import com.easyhooon.dari.RawPayloadFormatter
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.ExperimentalSerializationApi
@@ -181,8 +183,8 @@ internal object DariExporter {
MessageDirection.WEB_TO_APP -> "Web \u2192 App"
MessageDirection.APP_TO_WEB -> "App \u2192 Web"
}
- val requestSize = entry.requestData?.toByteArray(Charsets.UTF_8)?.size ?: 0
- val responseSize = entry.responseData?.toByteArray(Charsets.UTF_8)?.size ?: 0
+ val requestSize = entry.requestSizeBytes
+ val responseSize = entry.responseSizeBytes
return buildString {
appendLine("Handler: ${entry.handlerName}")
@@ -202,17 +204,40 @@ internal object DariExporter {
appendLine("Request size: ${formatSize(requestSize)}${if (entry.requestDataTruncated) " (truncated)" else ""}")
appendLine("Response size: ${formatSize(responseSize)}${if (entry.responseDataTruncated) " (truncated)" else ""}")
appendLine("Total size: ${formatSize(requestSize + responseSize)}")
+ entry.requestPayloadMetadata?.let { metadata ->
+ appendLine("Request content type: ${metadata.contentType}")
+ appendLine("Request decode status: ${metadata.decodeStatus}")
+ }
+ entry.responsePayloadMetadata?.let { metadata ->
+ appendLine("Response content type: ${metadata.contentType}")
+ appendLine("Response decode status: ${metadata.decodeStatus}")
+ }
appendLine()
- appendLine("---------- Request ----------")
+ appendLine("---------- Request (Decoded) ----------")
appendLine()
appendLine(formatJson(entry.requestData) ?: "(empty)")
+ entry.requestPayloadMetadata?.let { appendRawPreview("Request", it) }
appendLine()
- appendLine("---------- Response ----------")
+ appendLine("---------- Response (Decoded) ----------")
appendLine()
- append(formatJson(entry.responseData) ?: "(empty)")
+ appendLine(formatJson(entry.responseData) ?: "(empty)")
+ entry.responsePayloadMetadata?.let { appendRawPreview("Response", it) }
}
}
+ private fun StringBuilder.appendRawPreview(label: String, metadata: MessagePayloadMetadata) {
+ val preview = metadata.rawPreview ?: return
+ appendLine()
+ appendLine("---------- $label (Raw / Hex) ----------")
+ appendLine("Captured: ${preview.previewSizeBytes} of ${metadata.originalSizeBytes} bytes${if (preview.truncated) " (truncated)" else ""}")
+ appendLine()
+ appendLine(RawPayloadFormatter.formatHex(preview))
+ appendLine()
+ appendLine("---------- $label (Raw / Base64) ----------")
+ appendLine()
+ appendLine(preview.base64.ifEmpty { "(empty)" })
+ }
+
private fun formatJson(jsonString: String?): String? {
if (jsonString == null) return null
return try {
diff --git a/dari/src/main/kotlin/com/easyhooon/dari/export/ExportableMessage.kt b/dari/src/main/kotlin/com/easyhooon/dari/export/ExportableMessage.kt
index 325c46a..540439e 100644
--- a/dari/src/main/kotlin/com/easyhooon/dari/export/ExportableMessage.kt
+++ b/dari/src/main/kotlin/com/easyhooon/dari/export/ExportableMessage.kt
@@ -17,6 +17,18 @@ internal data class ExportableMessage(
@SerialName("response_data") val responseData: String? = null,
@SerialName("request_data_truncated") val requestDataTruncated: Boolean = false,
@SerialName("response_data_truncated") val responseDataTruncated: Boolean = false,
+ @SerialName("request_content_type") val requestContentType: String? = null,
+ @SerialName("request_original_size_bytes") val requestOriginalSizeBytes: Int? = null,
+ @SerialName("request_decode_status") val requestDecodeStatus: String? = null,
+ @SerialName("request_raw_preview_base64") val requestRawPreviewBase64: String? = null,
+ @SerialName("request_raw_preview_size_bytes") val requestRawPreviewSizeBytes: Int? = null,
+ @SerialName("request_raw_preview_truncated") val requestRawPreviewTruncated: Boolean? = null,
+ @SerialName("response_content_type") val responseContentType: String? = null,
+ @SerialName("response_original_size_bytes") val responseOriginalSizeBytes: Int? = null,
+ @SerialName("response_decode_status") val responseDecodeStatus: String? = null,
+ @SerialName("response_raw_preview_base64") val responseRawPreviewBase64: String? = null,
+ @SerialName("response_raw_preview_size_bytes") val responseRawPreviewSizeBytes: Int? = null,
+ @SerialName("response_raw_preview_truncated") val responseRawPreviewTruncated: Boolean? = null,
val status: String,
@SerialName("request_timestamp") val requestTimestamp: Long,
@SerialName("response_timestamp") val responseTimestamp: Long? = null,
@@ -36,6 +48,18 @@ internal fun MessageEntry.toExportable(): ExportableMessage = ExportableMessage(
responseData = responseData,
requestDataTruncated = requestDataTruncated,
responseDataTruncated = responseDataTruncated,
+ requestContentType = requestPayloadMetadata?.contentType?.name,
+ requestOriginalSizeBytes = requestPayloadMetadata?.originalSizeBytes,
+ requestDecodeStatus = requestPayloadMetadata?.decodeStatus?.name,
+ requestRawPreviewBase64 = requestPayloadMetadata?.rawPreview?.base64,
+ requestRawPreviewSizeBytes = requestPayloadMetadata?.rawPreview?.previewSizeBytes,
+ requestRawPreviewTruncated = requestPayloadMetadata?.rawPreview?.truncated,
+ responseContentType = responsePayloadMetadata?.contentType?.name,
+ responseOriginalSizeBytes = responsePayloadMetadata?.originalSizeBytes,
+ responseDecodeStatus = responsePayloadMetadata?.decodeStatus?.name,
+ responseRawPreviewBase64 = responsePayloadMetadata?.rawPreview?.base64,
+ responseRawPreviewSizeBytes = responsePayloadMetadata?.rawPreview?.previewSizeBytes,
+ responseRawPreviewTruncated = responsePayloadMetadata?.rawPreview?.truncated,
status = when (status) {
MessageStatus.IN_PROGRESS -> "IN_PROGRESS"
MessageStatus.SUCCESS -> "SUCCESS"
diff --git a/dari/src/main/kotlin/com/easyhooon/dari/interceptor/DefaultDariInterceptor.kt b/dari/src/main/kotlin/com/easyhooon/dari/interceptor/DefaultDariInterceptor.kt
index fbc3e62..f04d0ed 100644
--- a/dari/src/main/kotlin/com/easyhooon/dari/interceptor/DefaultDariInterceptor.kt
+++ b/dari/src/main/kotlin/com/easyhooon/dari/interceptor/DefaultDariInterceptor.kt
@@ -14,7 +14,11 @@ import com.easyhooon.dari.MessageStatus
*/
class DefaultDariInterceptor(
override val tag: String? = null,
-) : DariInterceptor {
+ private val protobufDecoder: ProtobufPayloadDecoder? = null,
+) : ProtobufDariInterceptor {
+
+ /** Preserves the original constructor for binary compatibility. */
+ constructor(tag: String?) : this(tag, null)
private val maxContentLength: Int
get() = Dari.config.maxContentLength
@@ -85,4 +89,121 @@ class DefaultDariInterceptor(
)
}
}
+
+ override fun onWebToAppProtobufRequest(
+ handlerName: String,
+ requestId: String?,
+ requestData: ByteArray,
+ fireAndForget: Boolean?,
+ ) {
+ val rendered = renderProtobuf(
+ payload = requestData,
+ handlerName = handlerName,
+ direction = MessageDirection.WEB_TO_APP,
+ part = PayloadPart.REQUEST,
+ )
+ val resolvedAsSuccess = fireAndForget ?: Dari.config.fireAndForget
+ val entry = MessageEntry(
+ requestId = requestId,
+ handlerName = handlerName,
+ direction = MessageDirection.WEB_TO_APP,
+ tag = tag,
+ requestData = rendered.data,
+ requestDataTruncated = rendered.wasTruncated,
+ requestPayloadMetadata = rendered.metadata,
+ status = if (resolvedAsSuccess) MessageStatus.SUCCESS else MessageStatus.IN_PROGRESS,
+ )
+ Dari.repository.addEntry(entry)
+ Dari.postMessageNotification(handlerName, MessageDirection.WEB_TO_APP, tag)
+ }
+
+ override fun onWebToAppProtobufResponse(
+ handlerName: String,
+ requestId: String?,
+ responseData: ByteArray,
+ isSuccess: Boolean,
+ ) {
+ if (requestId == null) return
+
+ val rendered = renderProtobuf(
+ payload = responseData,
+ handlerName = handlerName,
+ direction = MessageDirection.WEB_TO_APP,
+ part = PayloadPart.RESPONSE,
+ )
+ Dari.repository.updateEntry(requestId = requestId, tag = tag) { entry ->
+ entry.copy(
+ responseData = rendered.data,
+ responseDataTruncated = rendered.wasTruncated,
+ responsePayloadMetadata = rendered.metadata,
+ status = if (isSuccess) MessageStatus.SUCCESS else MessageStatus.ERROR,
+ responseTimestamp = System.currentTimeMillis(),
+ )
+ }
+ }
+
+ override fun onAppToWebProtobufRequest(
+ handlerName: String,
+ requestId: String?,
+ requestData: ByteArray,
+ fireAndForget: Boolean?,
+ ) {
+ val rendered = renderProtobuf(
+ payload = requestData,
+ handlerName = handlerName,
+ direction = MessageDirection.APP_TO_WEB,
+ part = PayloadPart.REQUEST,
+ )
+ val resolvedAsSuccess = fireAndForget ?: Dari.config.fireAndForget
+ val entry = MessageEntry(
+ requestId = requestId,
+ handlerName = handlerName,
+ direction = MessageDirection.APP_TO_WEB,
+ tag = tag,
+ requestData = rendered.data,
+ requestDataTruncated = rendered.wasTruncated,
+ requestPayloadMetadata = rendered.metadata,
+ status = if (resolvedAsSuccess) MessageStatus.SUCCESS else MessageStatus.IN_PROGRESS,
+ )
+ Dari.repository.addEntry(entry)
+ Dari.postMessageNotification(handlerName, MessageDirection.APP_TO_WEB, tag)
+ }
+
+ override fun onAppToWebProtobufResponse(
+ requestId: String?,
+ isSuccess: Boolean,
+ responseData: ByteArray,
+ ) {
+ if (requestId == null) return
+
+ Dari.repository.updateEntry(requestId = requestId, tag = tag) { entry ->
+ val rendered = renderProtobuf(
+ payload = responseData,
+ handlerName = entry.handlerName,
+ direction = MessageDirection.APP_TO_WEB,
+ part = PayloadPart.RESPONSE,
+ )
+ entry.copy(
+ responseData = rendered.data,
+ responseDataTruncated = rendered.wasTruncated,
+ responsePayloadMetadata = rendered.metadata,
+ status = if (isSuccess) MessageStatus.SUCCESS else MessageStatus.ERROR,
+ responseTimestamp = System.currentTimeMillis(),
+ )
+ }
+ }
+
+ private fun renderProtobuf(
+ payload: ByteArray,
+ handlerName: String,
+ direction: MessageDirection,
+ part: PayloadPart,
+ ): RenderedProtobufPayload {
+ return ProtobufPayloadRenderer.render(
+ payload = payload,
+ context = ProtobufDecodeContext(handlerName, direction, part),
+ decoder = protobufDecoder,
+ maxContentLength = maxContentLength,
+ )
+ }
}
diff --git a/dari/src/main/kotlin/com/easyhooon/dari/interceptor/ProtobufPayloadRenderer.kt b/dari/src/main/kotlin/com/easyhooon/dari/interceptor/ProtobufPayloadRenderer.kt
new file mode 100644
index 0000000..2d3d825
--- /dev/null
+++ b/dari/src/main/kotlin/com/easyhooon/dari/interceptor/ProtobufPayloadRenderer.kt
@@ -0,0 +1,57 @@
+package com.easyhooon.dari.interceptor
+
+import com.easyhooon.dari.MessageEntry
+import com.easyhooon.dari.MessagePayloadMetadata
+import com.easyhooon.dari.PayloadContentType
+import com.easyhooon.dari.PayloadDecodeStatus
+import com.easyhooon.dari.RawPayloadPreview
+import java.util.Base64
+
+internal data class RenderedProtobufPayload(
+ val data: String,
+ val wasTruncated: Boolean,
+ val metadata: MessagePayloadMetadata,
+)
+
+internal object ProtobufPayloadRenderer {
+ private const val RAW_PREVIEW_MAX_BYTES = 4 * 1024
+
+ fun render(
+ payload: ByteArray,
+ context: ProtobufDecodeContext,
+ decoder: ProtobufPayloadDecoder?,
+ maxContentLength: Int,
+ ): RenderedProtobufPayload {
+ val (displayData, decodeStatus) = try {
+ val decoded = decoder?.decode(payload, context)
+ if (decoded == null) {
+ "(protobuf decoder unavailable)" to PayloadDecodeStatus.DECODER_UNAVAILABLE
+ } else {
+ decoded to PayloadDecodeStatus.DECODED
+ }
+ } catch (_: Exception) {
+ "(protobuf decoding failed)" to PayloadDecodeStatus.FAILED
+ }
+ val (truncatedData, wasTruncated) = MessageEntry.truncateIfNeeded(displayData, maxContentLength)
+
+ return RenderedProtobufPayload(
+ data = truncatedData.orEmpty(),
+ wasTruncated = wasTruncated,
+ metadata = MessagePayloadMetadata(
+ contentType = PayloadContentType.PROTOBUF,
+ originalSizeBytes = payload.size,
+ decodeStatus = decodeStatus,
+ rawPreview = payload.toRawPreview(),
+ ),
+ )
+ }
+
+ private fun ByteArray.toRawPreview(): RawPayloadPreview {
+ val previewBytes = copyOf(minOf(size, RAW_PREVIEW_MAX_BYTES))
+ return RawPayloadPreview(
+ base64 = Base64.getEncoder().encodeToString(previewBytes),
+ previewSizeBytes = previewBytes.size,
+ truncated = previewBytes.size < size,
+ )
+ }
+}
diff --git a/dari/src/main/kotlin/com/easyhooon/dari/ui/DariDetailActivity.kt b/dari/src/main/kotlin/com/easyhooon/dari/ui/DariDetailActivity.kt
index be4c106..01b2f2f 100644
--- a/dari/src/main/kotlin/com/easyhooon/dari/ui/DariDetailActivity.kt
+++ b/dari/src/main/kotlin/com/easyhooon/dari/ui/DariDetailActivity.kt
@@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
@@ -25,6 +26,7 @@ import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@@ -49,8 +51,11 @@ import androidx.compose.ui.unit.dp
import com.easyhooon.dari.Dari
import com.easyhooon.dari.MessageDirection
import com.easyhooon.dari.MessageEntry
+import com.easyhooon.dari.MessagePayloadMetadata
+import com.easyhooon.dari.RawPayloadFormatter
import com.easyhooon.dari.export.DariExporter
import com.easyhooon.dari.export.ExportFormat
+import com.easyhooon.dari.ui.components.CodeViewer
import com.easyhooon.dari.ui.components.JsonViewer
import androidx.compose.foundation.isSystemInDarkTheme
import com.easyhooon.dari.ui.theme.ApplyDariSystemBars
@@ -250,8 +255,8 @@ private fun DetailTabs(entry: MessageEntry) {
) { page ->
when (page) {
0 -> OverviewTab(entry)
- 1 -> DataTab(entry.requestData)
- 2 -> DataTab(entry.responseData)
+ 1 -> DataTab(entry.requestData, entry.requestPayloadMetadata)
+ 2 -> DataTab(entry.responseData, entry.responsePayloadMetadata)
}
}
}
@@ -259,8 +264,8 @@ private fun DetailTabs(entry: MessageEntry) {
@Composable
private fun OverviewTab(entry: MessageEntry) {
- val requestSize = entry.requestData?.toByteArray(Charsets.UTF_8)?.size ?: 0
- val responseSize = entry.responseData?.toByteArray(Charsets.UTF_8)?.size ?: 0
+ val requestSize = entry.requestSizeBytes
+ val responseSize = entry.responseSizeBytes
Column(
modifier = Modifier
@@ -294,6 +299,20 @@ private fun OverviewTab(entry: MessageEntry) {
OverviewRow("Request size", formatSize(requestSize) + if (entry.requestDataTruncated) " (truncated)" else "")
OverviewRow("Response size", formatSize(responseSize) + if (entry.responseDataTruncated) " (truncated)" else "")
OverviewRow("Total size", formatSize(requestSize + responseSize))
+ entry.requestPayloadMetadata?.let { metadata ->
+ OverviewRow("Request type", metadata.contentType.name)
+ OverviewRow("Request decode", metadata.decodeStatus.name)
+ metadata.rawPreview?.let { preview ->
+ OverviewRow("Request raw", formatRawPreviewSize(preview.previewSizeBytes, metadata.originalSizeBytes))
+ }
+ }
+ entry.responsePayloadMetadata?.let { metadata ->
+ OverviewRow("Response type", metadata.contentType.name)
+ OverviewRow("Response decode", metadata.decodeStatus.name)
+ metadata.rawPreview?.let { preview ->
+ OverviewRow("Response raw", formatRawPreviewSize(preview.previewSizeBytes, metadata.originalSizeBytes))
+ }
+ }
}
}
@@ -318,8 +337,49 @@ private fun OverviewRow(label: String, value: String) {
}
}
+private enum class PayloadViewMode {
+ DECODED,
+ RAW,
+}
+
@Composable
-private fun DataTab(data: String?) {
+private fun DataTab(data: String?, metadata: MessagePayloadMetadata?) {
+ val rawPreview = metadata?.rawPreview
+ var viewMode by remember(rawPreview?.base64) { mutableStateOf(PayloadViewMode.DECODED) }
+
+ Column(
+ modifier = Modifier.fillMaxSize(),
+ ) {
+ if (rawPreview != null) {
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
+ ) {
+ FilterChip(
+ selected = viewMode == PayloadViewMode.DECODED,
+ onClick = { viewMode = PayloadViewMode.DECODED },
+ label = { Text("Decoded") },
+ )
+ FilterChip(
+ selected = viewMode == PayloadViewMode.RAW,
+ onClick = { viewMode = PayloadViewMode.RAW },
+ label = { Text("Raw") },
+ )
+ }
+ }
+
+ Box(modifier = Modifier.weight(1f)) {
+ if (viewMode == PayloadViewMode.RAW && rawPreview != null) {
+ RawPayloadView(metadata)
+ } else {
+ DecodedPayloadView(data)
+ }
+ }
+ }
+}
+
+@Composable
+private fun DecodedPayloadView(data: String?) {
Column(
modifier = Modifier
.fillMaxSize()
@@ -338,6 +398,47 @@ private fun DataTab(data: String?) {
}
}
+@Composable
+private fun RawPayloadView(metadata: MessagePayloadMetadata) {
+ val preview = metadata.rawPreview ?: return
+ val hex = remember(preview.base64) { RawPayloadFormatter.formatHex(preview) }
+
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState())
+ .padding(16.dp),
+ ) {
+ Text(
+ text = "Captured ${formatRawPreviewSize(preview.previewSizeBytes, metadata.originalSizeBytes)}",
+ style = MaterialTheme.typography.bodyMedium,
+ )
+ if (preview.truncated) {
+ Text(
+ text = "Raw preview is limited to the first ${formatSize(preview.previewSizeBytes)}.",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+
+ Spacer(modifier = Modifier.height(16.dp))
+ Text("HEX", fontWeight = FontWeight.Bold)
+ Spacer(modifier = Modifier.height(6.dp))
+ CodeViewer(hex)
+
+ Spacer(modifier = Modifier.height(16.dp))
+ Text("BASE64", fontWeight = FontWeight.Bold)
+ Spacer(modifier = Modifier.height(6.dp))
+ CodeViewer(preview.base64.ifEmpty { "(empty)" })
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ text = "Base64 is a display encoding of the captured bytes, not part of protobuf itself.",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+}
+
// `get()` instead of a cached val so a locale change at runtime is picked up
// on the next format call instead of staying pinned to the locale at class
// load time.
@@ -354,3 +455,10 @@ private fun formatSize(bytes: Int): String = when {
bytes < 1024 * 1024 -> "${"%.1f".format(bytes / 1024f)} KB"
else -> "${"%.1f".format(bytes / (1024f * 1024f))} MB"
}
+
+private fun formatRawPreviewSize(previewBytes: Int, originalBytes: Int): String =
+ if (previewBytes < originalBytes) {
+ "${formatSize(previewBytes)} of ${formatSize(originalBytes)} (truncated)"
+ } else {
+ formatSize(originalBytes)
+ }
diff --git a/dari/src/main/kotlin/com/easyhooon/dari/ui/components/JsonViewer.kt b/dari/src/main/kotlin/com/easyhooon/dari/ui/components/JsonViewer.kt
index fd99ad3..53b2e0d 100644
--- a/dari/src/main/kotlin/com/easyhooon/dari/ui/components/JsonViewer.kt
+++ b/dari/src/main/kotlin/com/easyhooon/dari/ui/components/JsonViewer.kt
@@ -38,8 +38,13 @@ internal fun JsonViewer(jsonString: String) {
}
}
+ CodeViewer(formatted)
+}
+
+@Composable
+internal fun CodeViewer(text: String) {
Text(
- text = formatted,
+ text = text,
modifier = Modifier
.fillMaxWidth()
.background(
diff --git a/dari/src/test/kotlin/com/easyhooon/dari/MessageEntryTest.kt b/dari/src/test/kotlin/com/easyhooon/dari/MessageEntryTest.kt
index 0f30cb6..8431761 100644
--- a/dari/src/test/kotlin/com/easyhooon/dari/MessageEntryTest.kt
+++ b/dari/src/test/kotlin/com/easyhooon/dari/MessageEntryTest.kt
@@ -66,6 +66,25 @@ class MessageEntryTest {
assertEquals(6, entry.totalSizeBytes)
}
+ @Test
+ fun `binary metadata size takes precedence over rendered text size`() {
+ val entry = MessageEntry(
+ handlerName = "test",
+ direction = MessageDirection.WEB_TO_APP,
+ requestData = "rendered protobuf text",
+ responseData = "response",
+ requestPayloadMetadata = MessagePayloadMetadata(
+ contentType = PayloadContentType.PROTOBUF,
+ originalSizeBytes = 4,
+ decodeStatus = PayloadDecodeStatus.DECODED,
+ ),
+ )
+
+ assertEquals(4, entry.requestSizeBytes)
+ assertEquals(8, entry.responseSizeBytes)
+ assertEquals(12, entry.totalSizeBytes)
+ }
+
@Test
fun `default status is IN_PROGRESS`() {
val entry = MessageEntry(
diff --git a/dari/src/test/kotlin/com/easyhooon/dari/RawPayloadFormatterTest.kt b/dari/src/test/kotlin/com/easyhooon/dari/RawPayloadFormatterTest.kt
new file mode 100644
index 0000000..b033883
--- /dev/null
+++ b/dari/src/test/kotlin/com/easyhooon/dari/RawPayloadFormatterTest.kt
@@ -0,0 +1,27 @@
+package com.easyhooon.dari
+
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class RawPayloadFormatterTest {
+ @Test
+ fun `formatHex groups bytes into sixteen byte lines`() {
+ val preview = RawPayloadPreview(
+ base64 = "AAECAwQFBgcICQoLDA0ODxA=",
+ previewSizeBytes = 17,
+ truncated = false,
+ )
+
+ assertEquals(
+ "00 01 02 03 04 05 06 07 08 09 0A 0B 0C 0D 0E 0F\n10",
+ RawPayloadFormatter.formatHex(preview),
+ )
+ }
+
+ @Test
+ fun `formatHex handles invalid preview safely`() {
+ val preview = RawPayloadPreview("not base64", 0, false)
+
+ assertEquals("(raw preview unavailable)", RawPayloadFormatter.formatHex(preview))
+ }
+}
diff --git a/dari/src/test/kotlin/com/easyhooon/dari/data/local/MessageEntityTest.kt b/dari/src/test/kotlin/com/easyhooon/dari/data/local/MessageEntityTest.kt
new file mode 100644
index 0000000..9e163b1
--- /dev/null
+++ b/dari/src/test/kotlin/com/easyhooon/dari/data/local/MessageEntityTest.kt
@@ -0,0 +1,39 @@
+package com.easyhooon.dari.data.local
+
+import com.easyhooon.dari.MessageDirection
+import com.easyhooon.dari.MessageEntry
+import com.easyhooon.dari.MessagePayloadMetadata
+import com.easyhooon.dari.PayloadContentType
+import com.easyhooon.dari.PayloadDecodeStatus
+import com.easyhooon.dari.RawPayloadPreview
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class MessageEntityTest {
+ @Test
+ fun `protobuf metadata survives entity round trip`() {
+ val metadata = MessagePayloadMetadata(
+ contentType = PayloadContentType.PROTOBUF,
+ originalSizeBytes = 42,
+ decodeStatus = PayloadDecodeStatus.DECODED,
+ rawPreview = RawPayloadPreview(
+ base64 = "AQID",
+ previewSizeBytes = 3,
+ truncated = true,
+ ),
+ )
+ val entry = MessageEntry(
+ requestId = "request-1",
+ handlerName = "createOrder",
+ direction = MessageDirection.WEB_TO_APP,
+ requestData = """{"productId":42}""",
+ requestPayloadMetadata = metadata,
+ )
+
+ val restored = entry.toEntity().copy(id = 7).toMessageEntry()
+
+ assertEquals(7, restored.id)
+ assertEquals(metadata, restored.requestPayloadMetadata)
+ assertEquals(42, restored.requestSizeBytes)
+ }
+}
diff --git a/dari/src/test/kotlin/com/easyhooon/dari/export/DariExporterTest.kt b/dari/src/test/kotlin/com/easyhooon/dari/export/DariExporterTest.kt
index 628f2c5..547af93 100644
--- a/dari/src/test/kotlin/com/easyhooon/dari/export/DariExporterTest.kt
+++ b/dari/src/test/kotlin/com/easyhooon/dari/export/DariExporterTest.kt
@@ -3,6 +3,10 @@ package com.easyhooon.dari.export
import com.easyhooon.dari.MessageDirection
import com.easyhooon.dari.MessageEntry
import com.easyhooon.dari.MessageStatus
+import com.easyhooon.dari.MessagePayloadMetadata
+import com.easyhooon.dari.PayloadContentType
+import com.easyhooon.dari.PayloadDecodeStatus
+import com.easyhooon.dari.RawPayloadPreview
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.jsonArray
@@ -133,6 +137,25 @@ class DariExporterTest {
assertTrue(text.contains("not json at all"))
}
+ @Test
+ fun `formatSingleEntry includes protobuf raw preview`() {
+ val metadata = MessagePayloadMetadata(
+ contentType = PayloadContentType.PROTOBUF,
+ originalSizeBytes = 4,
+ decodeStatus = PayloadDecodeStatus.DECODED,
+ rawPreview = RawPayloadPreview("AQID", 3, true),
+ )
+ val text = DariExporter.formatSingleEntry(
+ createEntry().copy(requestPayloadMetadata = metadata),
+ )
+
+ assertTrue(text.contains("Request (Raw / Hex)"))
+ assertTrue(text.contains("01 02 03"))
+ assertTrue(text.contains("Request (Raw / Base64)"))
+ assertTrue(text.contains("AQID"))
+ assertTrue(text.contains("Captured: 3 of 4 bytes (truncated)"))
+ }
+
@Test
fun `json serialization produces valid JSON array`() {
val entries = listOf(
diff --git a/dari/src/test/kotlin/com/easyhooon/dari/export/ExportableMessageTest.kt b/dari/src/test/kotlin/com/easyhooon/dari/export/ExportableMessageTest.kt
index ba933cc..5a1f10a 100644
--- a/dari/src/test/kotlin/com/easyhooon/dari/export/ExportableMessageTest.kt
+++ b/dari/src/test/kotlin/com/easyhooon/dari/export/ExportableMessageTest.kt
@@ -3,6 +3,10 @@ package com.easyhooon.dari.export
import com.easyhooon.dari.MessageDirection
import com.easyhooon.dari.MessageEntry
import com.easyhooon.dari.MessageStatus
+import com.easyhooon.dari.MessagePayloadMetadata
+import com.easyhooon.dari.PayloadContentType
+import com.easyhooon.dari.PayloadDecodeStatus
+import com.easyhooon.dari.RawPayloadPreview
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
@@ -108,4 +112,31 @@ class ExportableMessageTest {
assertEquals(true, exportable.requestDataTruncated)
assertEquals(true, exportable.responseDataTruncated)
}
+
+ @Test
+ fun `toExportable includes protobuf payload metadata`() {
+ val metadata = MessagePayloadMetadata(
+ contentType = PayloadContentType.PROTOBUF,
+ originalSizeBytes = 7,
+ decodeStatus = PayloadDecodeStatus.DECODED,
+ rawPreview = RawPayloadPreview("AQID", 3, true),
+ )
+ val exportable = createEntry().copy(
+ requestPayloadMetadata = metadata,
+ responsePayloadMetadata = metadata.copy(decodeStatus = PayloadDecodeStatus.FAILED),
+ ).toExportable()
+
+ assertEquals("PROTOBUF", exportable.requestContentType)
+ assertEquals(7, exportable.requestOriginalSizeBytes)
+ assertEquals("DECODED", exportable.requestDecodeStatus)
+ assertEquals("AQID", exportable.requestRawPreviewBase64)
+ assertEquals(3, exportable.requestRawPreviewSizeBytes)
+ assertEquals(true, exportable.requestRawPreviewTruncated)
+ assertEquals("PROTOBUF", exportable.responseContentType)
+ assertEquals(7, exportable.responseOriginalSizeBytes)
+ assertEquals("FAILED", exportable.responseDecodeStatus)
+ assertEquals("AQID", exportable.responseRawPreviewBase64)
+ assertEquals(3, exportable.responseRawPreviewSizeBytes)
+ assertEquals(true, exportable.responseRawPreviewTruncated)
+ }
}
diff --git a/dari/src/test/kotlin/com/easyhooon/dari/interceptor/ProtobufPayloadRendererTest.kt b/dari/src/test/kotlin/com/easyhooon/dari/interceptor/ProtobufPayloadRendererTest.kt
new file mode 100644
index 0000000..7a8e679
--- /dev/null
+++ b/dari/src/test/kotlin/com/easyhooon/dari/interceptor/ProtobufPayloadRendererTest.kt
@@ -0,0 +1,98 @@
+package com.easyhooon.dari.interceptor
+
+import com.easyhooon.dari.MessageDirection
+import com.easyhooon.dari.PayloadContentType
+import com.easyhooon.dari.PayloadDecodeStatus
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import java.util.Base64
+
+class ProtobufPayloadRendererTest {
+ private val context = ProtobufDecodeContext(
+ handlerName = "createOrder",
+ direction = MessageDirection.WEB_TO_APP,
+ part = PayloadPart.REQUEST,
+ )
+
+ @Test
+ fun `render uses decoder output and original binary size`() {
+ var receivedContext: ProtobufDecodeContext? = null
+ val result = ProtobufPayloadRenderer.render(
+ payload = byteArrayOf(1, 2, 3),
+ context = context,
+ decoder = ProtobufPayloadDecoder { _, decodeContext ->
+ receivedContext = decodeContext
+ """{"productId":42}"""
+ },
+ maxContentLength = 100,
+ )
+
+ assertEquals("""{"productId":42}""", result.data)
+ assertFalse(result.wasTruncated)
+ assertEquals(context, receivedContext)
+ assertEquals(PayloadContentType.PROTOBUF, result.metadata.contentType)
+ assertEquals(3, result.metadata.originalSizeBytes)
+ assertEquals(PayloadDecodeStatus.DECODED, result.metadata.decodeStatus)
+ assertEquals("AQID", result.metadata.rawPreview?.base64)
+ assertEquals(3, result.metadata.rawPreview?.previewSizeBytes)
+ assertFalse(result.metadata.rawPreview?.truncated ?: true)
+ }
+
+ @Test
+ fun `render records unavailable decoder without throwing`() {
+ val result = ProtobufPayloadRenderer.render(
+ payload = byteArrayOf(1),
+ context = context,
+ decoder = null,
+ maxContentLength = 100,
+ )
+
+ assertEquals("(protobuf decoder unavailable)", result.data)
+ assertEquals(PayloadDecodeStatus.DECODER_UNAVAILABLE, result.metadata.decodeStatus)
+ }
+
+ @Test
+ fun `render records decoder failure without exposing the exception`() {
+ val result = ProtobufPayloadRenderer.render(
+ payload = byteArrayOf(1),
+ context = context,
+ decoder = ProtobufPayloadDecoder { _, _ -> error("sensitive details") },
+ maxContentLength = 100,
+ )
+
+ assertEquals("(protobuf decoding failed)", result.data)
+ assertEquals(PayloadDecodeStatus.FAILED, result.metadata.decodeStatus)
+ }
+
+ @Test
+ fun `render truncates decoded display text`() {
+ val result = ProtobufPayloadRenderer.render(
+ payload = byteArrayOf(1, 2),
+ context = context,
+ decoder = ProtobufPayloadDecoder { _, _ -> "abcdefghij" },
+ maxContentLength = 5,
+ )
+
+ assertTrue(result.wasTruncated)
+ assertTrue(result.data.startsWith("abcde"))
+ assertEquals(2, result.metadata.originalSizeBytes)
+ }
+
+ @Test
+ fun `render limits raw payload preview to four kilobytes`() {
+ val result = ProtobufPayloadRenderer.render(
+ payload = ByteArray(4 * 1024 + 1) { it.toByte() },
+ context = context,
+ decoder = null,
+ maxContentLength = 100,
+ )
+
+ val preview = requireNotNull(result.metadata.rawPreview)
+ assertEquals(4 * 1024, preview.previewSizeBytes)
+ assertEquals(4 * 1024, Base64.getDecoder().decode(preview.base64).size)
+ assertTrue(preview.truncated)
+ assertEquals(4 * 1024 + 1, result.metadata.originalSizeBytes)
+ }
+}
diff --git a/documentation/content/docs/index.mdx b/documentation/content/docs/index.mdx
index da39781..b61f17b 100644
--- a/documentation/content/docs/index.mdx
+++ b/documentation/content/docs/index.mdx
@@ -15,6 +15,7 @@ Dari is a debug inspector library for Android WebView bridge communication, insp
- **Shake-to-open** gesture to launch the inspector
- **Status filter** to focus on errors or in-progress calls
- **Export** captured messages as JSON
+- **Optional Protocol Buffers inspection** with an app-provided decoder
- **No-op module** (`dari-noop`) for release builds with no runtime overhead
- **Fire-and-forget** support for one-way bridge calls
- **Same public API surface** across debug and release artifacts
diff --git a/documentation/content/docs/interceptor.mdx b/documentation/content/docs/interceptor.mdx
index 4fe6fbd..d6bcf6f 100644
--- a/documentation/content/docs/interceptor.mdx
+++ b/documentation/content/docs/interceptor.mdx
@@ -48,6 +48,37 @@ val interceptor = Dari.createInterceptor(tag = "PaymentBridge")
Use `Dari.createInterceptor()` in app code so release builds compile against `dari-noop`. The optional `tag` labels messages by bridge source when multiple interceptors are active in the same app.
+## Protocol Buffers (optional)
+
+The string/JSON interface remains the default. For protobuf payloads, provide a decoder when creating the interceptor:
+
+```kotlin
+val protobufInterceptor = Dari.createInterceptor(
+ tag = "OrderBridge",
+ protobufDecoder = ProtobufPayloadDecoder { payload, context ->
+ when (context.handlerName to context.part) {
+ "createOrder" to PayloadPart.REQUEST ->
+ JsonFormat.printer().print(CreateOrderRequest.parseFrom(payload))
+ "createOrder" to PayloadPart.RESPONSE ->
+ JsonFormat.printer().print(CreateOrderResponse.parseFrom(payload))
+ else -> null
+ }
+ },
+)
+
+protobufInterceptor?.onWebToAppProtobufRequest(
+ handlerName = "createOrder",
+ requestId = requestId,
+ requestData = protobufBytes,
+)
+```
+
+The decoder belongs to the consuming app, so Dari does not require a specific protobuf runtime. Returning `null` marks the decoder as unavailable for that handler. Decode failures are captured without crashing message inspection. Decoding runs synchronously on the interceptor caller's thread, so keep decoder work bounded.
+
+Base64 is not required by Dari. Decode Base64 before calling the interceptor when using a string-only bridge, or pass bytes received from an `ArrayBuffer` bridge directly.
+
+The REQUEST and RESPONSE tabs offer `Decoded` and `Raw` views. Dari retains at most the first 4 KB of each protobuf payload for the raw preview and displays it as Hex and Base64 while preserving the original byte size and truncation state. The displayed Base64 is generated for inspection and does not imply that the bridge used Base64 transport.
+
## Methods
### `onWebToAppRequest`
diff --git a/documentation/content/docs/ko/index.mdx b/documentation/content/docs/ko/index.mdx
index 67fa8f1..adfd4cb 100644
--- a/documentation/content/docs/ko/index.mdx
+++ b/documentation/content/docs/ko/index.mdx
@@ -15,6 +15,7 @@ Dari는 [Chucker](https://github.com/ChuckerTeam/chucker)에서 영감을 받아
- **흔들기로 열기**: 디바이스를 흔들어 인스펙터 실행
- **상태 필터**: 오류 또는 진행 중인 호출만 필터링
- **JSON 내보내기**: 캡처된 메시지를 JSON으로 내보내기
+- **선택적 Protocol Buffers 검사**: 앱에서 제공하는 decoder로 protobuf 페이로드 표시
- **No-op 모듈** (`dari-noop`): 릴리즈 빌드에서 런타임 오버헤드 제거
- **단방향(Fire-and-Forget) 지원**: 응답이 없는 브릿지 호출 처리
- **동일한 공개 API**: 디버그와 릴리즈 아티팩트의 API 표면 일치
diff --git a/documentation/content/docs/ko/interceptor.mdx b/documentation/content/docs/ko/interceptor.mdx
index b0c3d83..453a24a 100644
--- a/documentation/content/docs/ko/interceptor.mdx
+++ b/documentation/content/docs/ko/interceptor.mdx
@@ -48,6 +48,37 @@ val interceptor = Dari.createInterceptor(tag = "PaymentBridge")
앱 코드에서는 `Dari.createInterceptor()`를 사용해야 릴리즈 빌드가 `dari-noop`과 함께 컴파일됩니다. 선택적 `tag`를 사용하면 앱에 여러 인터셉터가 활성화된 경우 브릿지 소스별로 메시지를 구분할 수 있습니다.
+## Protocol Buffers (선택 사항)
+
+기존 String/JSON 인터페이스는 그대로 유지됩니다. protobuf 페이로드를 검사하려면 인터셉터를 생성할 때 앱의 decoder를 전달합니다:
+
+```kotlin
+val protobufInterceptor = Dari.createInterceptor(
+ tag = "OrderBridge",
+ protobufDecoder = ProtobufPayloadDecoder { payload, context ->
+ when (context.handlerName to context.part) {
+ "createOrder" to PayloadPart.REQUEST ->
+ JsonFormat.printer().print(CreateOrderRequest.parseFrom(payload))
+ "createOrder" to PayloadPart.RESPONSE ->
+ JsonFormat.printer().print(CreateOrderResponse.parseFrom(payload))
+ else -> null
+ }
+ },
+)
+
+protobufInterceptor?.onWebToAppProtobufRequest(
+ handlerName = "createOrder",
+ requestId = requestId,
+ requestData = protobufBytes,
+)
+```
+
+decoder는 사용하는 앱이 제공하므로 Dari는 특정 protobuf runtime에 의존하지 않습니다. 해당 handler를 지원하지 않으면 `null`을 반환할 수 있으며, 디코딩 실패가 메시지 캡처를 중단시키지 않습니다. 디코딩은 인터셉터 호출 스레드에서 동기 실행되므로 작업량을 제한해야 합니다.
+
+Base64는 Dari의 필수 조건이 아닙니다. 문자열 전용 브릿지에서는 Base64를 먼저 디코딩하고, `ArrayBuffer` 브릿지에서는 전달받은 bytes를 바로 사용할 수 있습니다.
+
+REQUEST와 RESPONSE 탭에서는 `Decoded`와 `Raw` 보기를 제공합니다. Dari는 raw preview를 위해 protobuf 페이로드의 앞부분을 최대 4KB까지만 보관하며, 원본 바이트 크기와 잘림 여부를 유지한 채 Hex와 Base64로 표시합니다. 이 Base64는 검사를 위해 Dari가 생성한 표시 형식이며, 실제 브릿지가 Base64로 운반했다는 의미는 아닙니다.
+
## 메서드
### `onWebToAppRequest`
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 5da9a2a..402298d 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -19,6 +19,7 @@ vanniktechMavenPublish = "0.31.0"
detekt = "1.23.8"
ktlintGradle = "11.6.1"
ktlintSource = "0.50.0"
+protobuf = "4.35.0"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -48,6 +49,7 @@ androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref =
androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
androidx-room-testing = { group = "androidx.room", name = "room-testing", version.ref = "room" }
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinxCoroutines" }
+protobuf-javalite = { group = "com.google.protobuf", name = "protobuf-javalite", version.ref = "protobuf" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
diff --git a/sample/build.gradle.kts b/sample/build.gradle.kts
index 689f889..9fd7ba3 100644
--- a/sample/build.gradle.kts
+++ b/sample/build.gradle.kts
@@ -49,6 +49,7 @@ dependencies {
implementation(libs.androidx.compose.ui.graphics)
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.compose.material3)
+ implementation(libs.protobuf.javalite)
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
diff --git a/sample/src/main/assets/sample.html b/sample/src/main/assets/sample.html
index 18f3a6e..14853ec 100644
--- a/sample/src/main/assets/sample.html
+++ b/sample/src/main/assets/sample.html
@@ -134,6 +134,12 @@
Dari Bridge Sample
No flag + no response = forever IN_PROGRESS (contrast)
+