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 DecodedProtobuf 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) +
Protocol Buffers
+ +
- \ No newline at end of file + diff --git a/sample/src/main/java/com/easyhooon/dari/sample/MainActivity.kt b/sample/src/main/java/com/easyhooon/dari/sample/MainActivity.kt index f04a179..27cd5f3 100644 --- a/sample/src/main/java/com/easyhooon/dari/sample/MainActivity.kt +++ b/sample/src/main/java/com/easyhooon/dari/sample/MainActivity.kt @@ -11,6 +11,7 @@ import android.os.Bundle import android.os.VibrationEffect import android.os.VibratorManager import android.provider.Settings +import android.util.Base64 import android.webkit.JavascriptInterface import android.webkit.WebView import android.webkit.WebViewClient @@ -20,16 +21,29 @@ import androidx.activity.enableEdgeToEdge import androidx.activity.result.contract.ActivityResultContracts import androidx.annotation.RequiresApi import androidx.lifecycle.lifecycleScope +import com.easyhooon.dari.Dari +import com.easyhooon.dari.interceptor.ProtobufDariInterceptor +import com.easyhooon.dari.interceptor.ProtobufPayloadDecoder +import com.google.protobuf.StringValue import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch -import com.easyhooon.dari.Dari -import com.easyhooon.dari.interceptor.DariInterceptor import org.json.JSONObject class MainActivity : ComponentActivity() { - private val interceptor: DariInterceptor? = Dari.createInterceptor(tag = "Sample") + private val interceptor: ProtobufDariInterceptor? = Dari.createInterceptor( + tag = "Sample", + protobufDecoder = ProtobufPayloadDecoder { payload, context -> + if (context.handlerName != PROTOBUF_HANDLER) return@ProtobufPayloadDecoder null + + JSONObject().apply { + put("type", "google.protobuf.StringValue") + put("value", StringValue.parseFrom(payload).value) + put("part", context.part.name) + }.toString(2) + }, + ) private var webView: WebView? = null // Pending camera permission request info @@ -85,6 +99,16 @@ class MainActivity : ComponentActivity() { } } + private fun callProtobufJs(requestId: String, success: Boolean, data: ByteArray) { + val base64Data = Base64.encodeToString(data, Base64.NO_WRAP) + webView?.post { + webView?.evaluateJavascript( + "javascript:onProtobufBridgeResponse('$requestId', $success, '$base64Data')", + null, + ) + } + } + inner class BridgeInterface { @RequiresApi(Build.VERSION_CODES.P) @JavascriptInterface @@ -114,6 +138,34 @@ class MainActivity : ComponentActivity() { } } + @JavascriptInterface + fun onProtobufBridgeRequest(requestId: String, base64Data: String) { + val requestData = Base64.decode(base64Data, Base64.NO_WRAP) + interceptor?.onWebToAppProtobufRequest(PROTOBUF_HANDLER, requestId, requestData) + + val result = runCatching { + val request = StringValue.parseFrom(requestData) + StringValue.newBuilder() + .setValue("Android received: ${request.value}") + .build() + } + val response = result.getOrElse { error -> + StringValue.newBuilder() + .setValue("Invalid protobuf: ${error.message}") + .build() + } + val success = result.isSuccess + val responseData = response.toByteArray() + + interceptor?.onWebToAppProtobufResponse( + PROTOBUF_HANDLER, + requestId, + responseData, + success, + ) + callProtobufJs(requestId, success, responseData) + } + /** * Fire-and-forget bridge request without requestId. * No response will be sent back to the web side. @@ -305,4 +357,8 @@ class MainActivity : ComponentActivity() { // In a real app, this would send to analytics service android.util.Log.d("Dari-Sample", "Screen view: $screen at $timestamp") } + + private companion object { + const val PROTOBUF_HANDLER = "protobufGreeting" + } } diff --git a/screenshots/protobuf_decoded.png b/screenshots/protobuf_decoded.png new file mode 100644 index 0000000..99ac345 Binary files /dev/null and b/screenshots/protobuf_decoded.png differ diff --git a/screenshots/protobuf_raw.png b/screenshots/protobuf_raw.png new file mode 100644 index 0000000..8aa8450 Binary files /dev/null and b/screenshots/protobuf_raw.png differ