Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.

<table>
<tr>
<td align="center"><b>Protobuf Decoded</b></td>
<td align="center"><b>Protobuf Raw Bytes</b></td>
</tr>
<tr>
<td><img src="screenshots/protobuf_decoded.png" width="320" /></td>
<td><img src="screenshots/protobuf_raw.png" width="320" /></td>
Comment on lines +176 to +177

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Add alternative text to both screenshots.

The <img> elements on Lines 176 and 177 have no alt attributes. Add concise descriptions for screen-reader users.

Proposed fix
-    <td><img src="screenshots/protobuf_decoded.png" width="320" /></td>
-    <td><img src="screenshots/protobuf_raw.png" width="320" /></td>
+    <td><img src="screenshots/protobuf_decoded.png" alt="Protobuf decoded payload view" width="320" /></td>
+    <td><img src="screenshots/protobuf_raw.png" alt="Protobuf raw bytes view" width="320" /></td>
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<td><img src="screenshots/protobuf_decoded.png" width="320" /></td>
<td><img src="screenshots/protobuf_raw.png" width="320" /></td>
<td><img src="screenshots/protobuf_decoded.png" alt="Protobuf decoded payload view" width="320" /></td>
<td><img src="screenshots/protobuf_raw.png" alt="Protobuf raw bytes view" width="320" /></td>
🧰 Tools
🪛 markdownlint-cli2 (0.23.1)

[warning] 176-176: Images should have alternate text (alt text)

(MD045, no-alt-text)


[warning] 177-177: Images should have alternate text (alt text)

(MD045, no-alt-text)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@README.md` around lines 176 - 177, Add concise, descriptive alt attributes to
both img elements for protobuf_decoded.png and protobuf_raw.png, while
preserving their existing sources and dimensions.

Source: Linters/SAST tools

</tr>
</table>

### Custom Configuration

You can customize Dari by calling `init` with a config before auto-initialization occurs, or in your `Application.onCreate()`:
Expand Down Expand Up @@ -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) |
Expand All @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion dari-core/src/main/kotlin/com/easyhooon/dari/DariConfig.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
49 changes: 43 additions & 6 deletions dari-core/src/main/kotlin/com/easyhooon/dari/MessageEntry.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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 {
/**
Expand All @@ -73,3 +108,5 @@ data class MessageEntry(
}
}
}

private fun String?.utf8Size(): Int = this?.toByteArray(Charsets.UTF_8)?.size ?: 0
Original file line number Diff line number Diff line change
@@ -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,
}
Original file line number Diff line number Diff line change
@@ -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,
)
}
8 changes: 8 additions & 0 deletions dari-noop/src/main/kotlin/com/easyhooon/dari/Dari.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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

Expand Down
11 changes: 11 additions & 0 deletions dari/src/main/kotlin/com/easyhooon/dari/Dari.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
*/
Expand Down
20 changes: 20 additions & 0 deletions dari/src/main/kotlin/com/easyhooon/dari/RawPayloadFormatter.kt
Original file line number Diff line number Diff line change
@@ -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) }
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down
14 changes: 14 additions & 0 deletions dari/src/main/kotlin/com/easyhooon/dari/data/local/Converters.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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")
}
}
}
}
Loading
Loading