From 046609f8944ef53c614446e3600693926569df47 Mon Sep 17 00:00:00 2001 From: easyhooon Date: Wed, 2 Sep 2026 16:31:28 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EB=B8=8C=EB=A6=BF=EC=A7=80=20=ED=95=B8?= =?UTF-8?q?=EB=93=A4=EB=9F=AC=20=ED=91=9C=EC=8B=9C=20=EC=9D=B4=EB=A6=84=20?= =?UTF-8?q?=EC=A7=80=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 안정적인 handlerName과 선택적 displayName을 분리해 저장하고 표시한다. 기존 인터셉터 시그니처와 noop API 호환성을 유지한다. --- README.md | 20 ++++- .../kotlin/com/easyhooon/dari/MessageEntry.kt | 37 ++++++++ .../dari/interceptor/DariInterceptor.kt | 22 +++++ .../interceptor/ProtobufDariInterceptor.kt | 20 +++++ .../com/easyhooon/dari/MessageEntryTest.kt | 25 ++++++ .../com/easyhooon/dari/PublicApiSmokeTest.kt | 87 ++++++++++++++++++- .../com/easyhooon/dari/DariNoopApiTest.kt | 20 +++++ .../main/kotlin/com/easyhooon/dari/Dari.kt | 9 +- .../com/easyhooon/dari/HandlerPresentation.kt | 10 +++ .../easyhooon/dari/data/local/DariDatabase.kt | 10 ++- .../dari/data/local/MessageEntity.kt | 3 + .../com/easyhooon/dari/export/DariExporter.kt | 3 + .../dari/export/ExportableMessage.kt | 2 + .../interceptor/DefaultDariInterceptor.kt | 60 +++++++++++-- .../dari/notification/DariNotification.kt | 10 ++- .../com/easyhooon/dari/ui/DariActivity.kt | 3 +- .../easyhooon/dari/ui/DariDetailActivity.kt | 8 +- .../dari/ui/components/MessageListItem.kt | 3 +- .../easyhooon/dari/HandlerPresentationTest.kt | 39 +++++++++ .../dari/data/local/MessageEntityTest.kt | 15 ++++ .../easyhooon/dari/export/DariExporterTest.kt | 11 +++ .../dari/export/ExportableMessageTest.kt | 10 +++ documentation/content/docs/interceptor.mdx | 31 +++++++ documentation/content/docs/ko/interceptor.mdx | 31 +++++++ documentation/content/docs/ko/protobuf.mdx | 3 +- documentation/content/docs/protobuf.mdx | 3 +- 26 files changed, 475 insertions(+), 20 deletions(-) create mode 100644 dari-noop/src/test/kotlin/com/easyhooon/dari/DariNoopApiTest.kt create mode 100644 dari/src/main/kotlin/com/easyhooon/dari/HandlerPresentation.kt create mode 100644 dari/src/test/kotlin/com/easyhooon/dari/HandlerPresentationTest.kt diff --git a/README.md b/README.md index 7454940..eb84383 100644 --- a/README.md +++ b/README.md @@ -266,9 +266,9 @@ keep the same API surface without storing or displaying bridge messages. | Method | Description | |--------|-------------| -| `onWebToAppRequest()` | Log a Web-to-App request. `requestId` is optional for fire-and-forget messages. | +| `onWebToAppRequest()` | Log a Web-to-App request. An overload accepts a separate optional `displayName`. | | `onWebToAppResponse()` | Log the response to a Web-to-App request. Skipped if `requestId` is null. | -| `onAppToWebRequest()` | Log an App-to-Web message. `requestId` is optional for fire-and-forget messages. | +| `onAppToWebRequest()` | Log an App-to-Web message. An overload accepts a separate optional `displayName`. | | `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. @@ -291,6 +291,14 @@ interface DariInterceptor { fireAndForget: Boolean? = null, ) + fun onWebToAppRequest( + handlerName: String, + displayName: String?, + requestId: String?, + requestData: String?, + fireAndForget: Boolean? = null, + ) + /** Called when a response is sent for a Web -> App request */ fun onWebToAppResponse( handlerName: String, @@ -307,6 +315,14 @@ interface DariInterceptor { fireAndForget: Boolean? = null, ) + fun onAppToWebRequest( + handlerName: String, + displayName: String?, + requestId: String?, + data: String?, + fireAndForget: Boolean? = null, + ) + /** Called when a web response is received for an App -> Web request */ fun onAppToWebResponse( requestId: String?, diff --git a/dari-core/src/commonMain/kotlin/com/easyhooon/dari/MessageEntry.kt b/dari-core/src/commonMain/kotlin/com/easyhooon/dari/MessageEntry.kt index 1d9ed98..1e977d6 100644 --- a/dari-core/src/commonMain/kotlin/com/easyhooon/dari/MessageEntry.kt +++ b/dari-core/src/commonMain/kotlin/com/easyhooon/dari/MessageEntry.kt @@ -7,6 +7,8 @@ package com.easyhooon.dari * @property id Auto-generated unique identifier for list key and entry lookup. * @property requestId Optional external request ID for matching request-response pairs. * When null, the entry is treated as a standalone (fire-and-forget) message. + * @property handlerName Stable bridge handler identifier. + * @property displayName Optional human-readable handler description. */ data class MessageEntry( val id: Long = 0L, @@ -23,8 +25,43 @@ data class MessageEntry( val responseTimestamp: Long? = null, val requestPayloadMetadata: MessagePayloadMetadata? = null, val responsePayloadMetadata: MessagePayloadMetadata? = null, + val displayName: String? = null, ) { + /** Preserves the pre-display-name constructor signature for binary callers. */ + 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?, + requestPayloadMetadata: MessagePayloadMetadata?, + responsePayloadMetadata: MessagePayloadMetadata?, + ) : 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 = requestPayloadMetadata, + responsePayloadMetadata = responsePayloadMetadata, + displayName = null, + ) + /** Preserves the pre-protobuf constructor for binary compatibility. */ constructor( id: Long, diff --git a/dari-core/src/commonMain/kotlin/com/easyhooon/dari/interceptor/DariInterceptor.kt b/dari-core/src/commonMain/kotlin/com/easyhooon/dari/interceptor/DariInterceptor.kt index ee0d304..37f0f06 100644 --- a/dari-core/src/commonMain/kotlin/com/easyhooon/dari/interceptor/DariInterceptor.kt +++ b/dari-core/src/commonMain/kotlin/com/easyhooon/dari/interceptor/DariInterceptor.kt @@ -16,12 +16,34 @@ interface DariInterceptor { /** Called when a Web -> App request is received */ fun onWebToAppRequest(handlerName: String, requestId: String?, requestData: String?, fireAndForget: Boolean? = null) + /** Called when a Web -> App request has a separate human-readable name. */ + fun onWebToAppRequest( + handlerName: String, + displayName: String?, + requestId: String?, + requestData: String?, + fireAndForget: Boolean? = null, + ) { + onWebToAppRequest(handlerName, requestId, requestData, fireAndForget) + } + /** Called when a response is sent for a Web -> App request */ fun onWebToAppResponse(handlerName: String, requestId: String?, responseData: String?, isSuccess: Boolean) /** Called when an App -> Web request is sent */ fun onAppToWebRequest(handlerName: String, requestId: String?, data: String?, fireAndForget: Boolean? = null) + /** Called when an App -> Web request has a separate human-readable name. */ + fun onAppToWebRequest( + handlerName: String, + displayName: String?, + requestId: String?, + data: String?, + fireAndForget: Boolean? = null, + ) { + onAppToWebRequest(handlerName, requestId, data, fireAndForget) + } + /** Called when a web response is received for an App -> Web request */ fun onAppToWebResponse(requestId: String?, isSuccess: Boolean, responseData: String?) } diff --git a/dari-core/src/commonMain/kotlin/com/easyhooon/dari/interceptor/ProtobufDariInterceptor.kt b/dari-core/src/commonMain/kotlin/com/easyhooon/dari/interceptor/ProtobufDariInterceptor.kt index 19d3eeb..5831f1f 100644 --- a/dari-core/src/commonMain/kotlin/com/easyhooon/dari/interceptor/ProtobufDariInterceptor.kt +++ b/dari-core/src/commonMain/kotlin/com/easyhooon/dari/interceptor/ProtobufDariInterceptor.kt @@ -32,6 +32,16 @@ interface ProtobufDariInterceptor : DariInterceptor { fireAndForget: Boolean? = null, ) + fun onWebToAppProtobufRequest( + handlerName: String, + displayName: String?, + requestId: String?, + requestData: ByteArray, + fireAndForget: Boolean? = null, + ) { + onWebToAppProtobufRequest(handlerName, requestId, requestData, fireAndForget) + } + fun onWebToAppProtobufResponse( handlerName: String, requestId: String?, @@ -46,6 +56,16 @@ interface ProtobufDariInterceptor : DariInterceptor { fireAndForget: Boolean? = null, ) + fun onAppToWebProtobufRequest( + handlerName: String, + displayName: String?, + requestId: String?, + requestData: ByteArray, + fireAndForget: Boolean? = null, + ) { + onAppToWebProtobufRequest(handlerName, requestId, requestData, fireAndForget) + } + fun onAppToWebProtobufResponse( requestId: String?, isSuccess: Boolean, diff --git a/dari-core/src/commonTest/kotlin/com/easyhooon/dari/MessageEntryTest.kt b/dari-core/src/commonTest/kotlin/com/easyhooon/dari/MessageEntryTest.kt index df5f79e..116d0df 100644 --- a/dari-core/src/commonTest/kotlin/com/easyhooon/dari/MessageEntryTest.kt +++ b/dari-core/src/commonTest/kotlin/com/easyhooon/dari/MessageEntryTest.kt @@ -27,4 +27,29 @@ class MessageEntryTest { assertEquals(2, entry.responseSizeBytes) assertEquals(13, entry.totalSizeBytes) } + + @Test + fun `display name is stored separately from the stable handler name`() { + val entry = MessageEntry( + handlerName = "N:4-1", + direction = MessageDirection.WEB_TO_APP, + displayName = "오늘의 건강목표", + ) + + assertEquals("N:4-1", entry.handlerName) + assertEquals("오늘의 건강목표", entry.displayName) + } + + @Test + fun `display name remains attached when a response updates the entry`() { + val entry = MessageEntry( + handlerName = "N:4-1", + direction = MessageDirection.WEB_TO_APP, + displayName = "오늘의 건강목표", + ) + + val completed = entry.copy(status = MessageStatus.SUCCESS, responseData = "done") + + assertEquals("오늘의 건강목표", completed.displayName) + } } diff --git a/dari-core/src/commonTest/kotlin/com/easyhooon/dari/PublicApiSmokeTest.kt b/dari-core/src/commonTest/kotlin/com/easyhooon/dari/PublicApiSmokeTest.kt index ffade67..96860f8 100644 --- a/dari-core/src/commonTest/kotlin/com/easyhooon/dari/PublicApiSmokeTest.kt +++ b/dari-core/src/commonTest/kotlin/com/easyhooon/dari/PublicApiSmokeTest.kt @@ -1,6 +1,7 @@ package com.easyhooon.dari import com.easyhooon.dari.interceptor.DariInterceptor +import com.easyhooon.dari.interceptor.ProtobufDariInterceptor import kotlin.test.Test import kotlin.test.assertEquals @@ -31,6 +32,30 @@ class PublicApiSmokeTest { assertEquals("handler", (interceptor as RecordingInterceptor).handlerName) } + @Test + fun `display name overload remains compatible with existing interceptor implementations`() { + val interceptor: DariInterceptor = RecordingInterceptor() + + interceptor.onWebToAppRequest( + handlerName = "N:4-1", + displayName = "오늘의 건강목표", + requestId = "request-id", + requestData = "payload", + ) + + assertEquals("N:4-1", (interceptor as RecordingInterceptor).handlerName) + } + + @Test + fun `protobuf display name overloads remain compatible with existing implementations`() { + val interceptor: ProtobufDariInterceptor = RecordingProtobufInterceptor() + + interceptor.onWebToAppProtobufRequest("N:4-1", "오늘의 건강목표", "web-request", byteArrayOf(1)) + interceptor.onAppToWebProtobufRequest("N:4-2", "이번 주 건강목표", "app-request", byteArrayOf(2)) + + assertEquals(listOf("N:4-1", "N:4-2"), (interceptor as RecordingProtobufInterceptor).handlerNames) + } + private class RecordingInterceptor : DariInterceptor { var handlerName: String? = null @@ -39,7 +64,9 @@ class PublicApiSmokeTest { requestId: String?, requestData: String?, fireAndForget: Boolean?, - ) = Unit + ) { + this.handlerName = handlerName + } override fun onWebToAppResponse( handlerName: String, @@ -59,4 +86,62 @@ class PublicApiSmokeTest { override fun onAppToWebResponse(requestId: String?, isSuccess: Boolean, responseData: String?) = Unit } + + private class RecordingProtobufInterceptor : ProtobufDariInterceptor { + val handlerNames = mutableListOf() + + override fun onWebToAppRequest( + handlerName: String, + requestId: String?, + requestData: String?, + fireAndForget: Boolean?, + ) = Unit + + override fun onWebToAppResponse( + handlerName: String, + requestId: String?, + responseData: String?, + isSuccess: Boolean, + ) = Unit + + override fun onAppToWebRequest( + handlerName: String, + requestId: String?, + data: String?, + fireAndForget: Boolean?, + ) = Unit + + override fun onAppToWebResponse(requestId: String?, isSuccess: Boolean, responseData: String?) = Unit + + override fun onWebToAppProtobufRequest( + handlerName: String, + requestId: String?, + requestData: ByteArray, + fireAndForget: Boolean?, + ) { + handlerNames += handlerName + } + + override fun onWebToAppProtobufResponse( + handlerName: String, + requestId: String?, + responseData: ByteArray, + isSuccess: Boolean, + ) = Unit + + override fun onAppToWebProtobufRequest( + handlerName: String, + requestId: String?, + requestData: ByteArray, + fireAndForget: Boolean?, + ) { + handlerNames += handlerName + } + + override fun onAppToWebProtobufResponse( + requestId: String?, + isSuccess: Boolean, + responseData: ByteArray, + ) = Unit + } } diff --git a/dari-noop/src/test/kotlin/com/easyhooon/dari/DariNoopApiTest.kt b/dari-noop/src/test/kotlin/com/easyhooon/dari/DariNoopApiTest.kt new file mode 100644 index 0000000..bd2b5ff --- /dev/null +++ b/dari-noop/src/test/kotlin/com/easyhooon/dari/DariNoopApiTest.kt @@ -0,0 +1,20 @@ +package com.easyhooon.dari + +import org.junit.Assert.assertNull +import org.junit.Test + +class DariNoopApiTest { + @Test + fun `display name request API remains a no-op`() { + val interceptor = Dari.createInterceptor() + + interceptor?.onWebToAppRequest( + handlerName = "N:4-1", + displayName = "오늘의 건강목표", + requestId = "request-id", + requestData = "payload", + ) + + assertNull(interceptor) + } +} diff --git a/dari/src/main/kotlin/com/easyhooon/dari/Dari.kt b/dari/src/main/kotlin/com/easyhooon/dari/Dari.kt index 1fddaac..25ec014 100644 --- a/dari/src/main/kotlin/com/easyhooon/dari/Dari.kt +++ b/dari/src/main/kotlin/com/easyhooon/dari/Dari.kt @@ -149,8 +149,13 @@ object Dari { /** * Adds a new message to the notification. */ - internal fun postMessageNotification(handlerName: String, direction: MessageDirection, tag: String? = null) { - notification?.postMessage(handlerName, direction, tag) + internal fun postMessageNotification( + handlerName: String, + displayName: String?, + direction: MessageDirection, + tag: String? = null, + ) { + notification?.postMessage(handlerName, displayName, direction, tag) } /** diff --git a/dari/src/main/kotlin/com/easyhooon/dari/HandlerPresentation.kt b/dari/src/main/kotlin/com/easyhooon/dari/HandlerPresentation.kt new file mode 100644 index 0000000..2edbdda --- /dev/null +++ b/dari/src/main/kotlin/com/easyhooon/dari/HandlerPresentation.kt @@ -0,0 +1,10 @@ +package com.easyhooon.dari + +internal fun handlerLabel(handlerName: String, displayName: String?): String { + val label = displayName?.takeIf { it.isNotBlank() } ?: return handlerName + return "$handlerName($label)" +} + +internal fun MessageEntry.matchesHandlerQuery(query: String): Boolean = + handlerName.contains(query, ignoreCase = true) || + displayName?.contains(query, ignoreCase = true) == true 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 cdb98d2..f2a5f19 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 @@ -10,7 +10,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase @Database( entities = [MessageEntity::class], - version = 5, + version = 6, exportSchema = false, ) @TypeConverters(Converters::class) @@ -23,7 +23,7 @@ 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) + .addMigrations(MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6) .fallbackToDestructiveMigration(dropAllTables = true) .build() } @@ -49,5 +49,11 @@ internal abstract class DariDatabase : RoomDatabase() { db.execSQL("ALTER TABLE `messages` ADD COLUMN `responseRawPreviewTruncated` INTEGER") } } + + private val MIGRATION_5_6 = object : Migration(5, 6) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE `messages` ADD COLUMN `displayName` TEXT") + } + } } } 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 757c268..8e5fb39 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 @@ -16,6 +16,7 @@ internal data class MessageEntity( val id: Long = 0, val requestId: String?, val handlerName: String, + val displayName: String? = null, val direction: MessageDirection, val tag: String? = null, val requestData: String? = null, @@ -42,6 +43,7 @@ internal data class MessageEntity( internal fun MessageEntry.toEntity(): MessageEntity = MessageEntity( requestId = requestId, handlerName = handlerName, + displayName = displayName, direction = direction, tag = tag, requestData = requestData, @@ -69,6 +71,7 @@ internal fun MessageEntity.toMessageEntry(): MessageEntry = MessageEntry( id = id, requestId = requestId, handlerName = handlerName, + displayName = displayName, direction = direction, tag = tag, requestData = requestData, 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 a0467c2..5027fcf 100644 --- a/dari/src/main/kotlin/com/easyhooon/dari/export/DariExporter.kt +++ b/dari/src/main/kotlin/com/easyhooon/dari/export/DariExporter.kt @@ -188,6 +188,9 @@ internal object DariExporter { return buildString { appendLine("Handler: ${entry.handlerName}") + entry.displayName?.takeIf { it.isNotBlank() }?.let { + appendLine("Display name: $it") + } appendLine("Direction: $direction") appendLine("Status: ${entry.status}") appendLine("Tag: ${entry.tag ?: "-"}") 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 540439e..a665642 100644 --- a/dari/src/main/kotlin/com/easyhooon/dari/export/ExportableMessage.kt +++ b/dari/src/main/kotlin/com/easyhooon/dari/export/ExportableMessage.kt @@ -11,6 +11,7 @@ internal data class ExportableMessage( val id: Long, @SerialName("request_id") val requestId: String? = null, @SerialName("handler_name") val handlerName: String, + @SerialName("display_name") val displayName: String? = null, val direction: String, val tag: String? = null, @SerialName("request_data") val requestData: String? = null, @@ -39,6 +40,7 @@ internal fun MessageEntry.toExportable(): ExportableMessage = ExportableMessage( id = id, requestId = requestId, handlerName = handlerName, + displayName = displayName, direction = when (direction) { MessageDirection.WEB_TO_APP -> "WEB_TO_APP" MessageDirection.APP_TO_WEB -> "APP_TO_WEB" 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 f04d0ed..55447b8 100644 --- a/dari/src/main/kotlin/com/easyhooon/dari/interceptor/DefaultDariInterceptor.kt +++ b/dari/src/main/kotlin/com/easyhooon/dari/interceptor/DefaultDariInterceptor.kt @@ -23,7 +23,20 @@ class DefaultDariInterceptor( private val maxContentLength: Int get() = Dari.config.maxContentLength - override fun onWebToAppRequest(handlerName: String, requestId: String?, requestData: String?, fireAndForget: Boolean?) { + override fun onWebToAppRequest( + handlerName: String, + requestId: String?, + requestData: String?, + fireAndForget: Boolean?, + ) = onWebToAppRequest(handlerName, null, requestId, requestData, fireAndForget) + + override fun onWebToAppRequest( + handlerName: String, + displayName: String?, + requestId: String?, + requestData: String?, + fireAndForget: Boolean?, + ) { val (truncatedData, wasTruncated) = MessageEntry.truncateIfNeeded(requestData, maxContentLength) val resolvedAsSuccess = fireAndForget ?: Dari.config.fireAndForget val entry = MessageEntry( @@ -34,9 +47,10 @@ class DefaultDariInterceptor( requestData = truncatedData, requestDataTruncated = wasTruncated, status = if (resolvedAsSuccess) MessageStatus.SUCCESS else MessageStatus.IN_PROGRESS, + displayName = displayName.normalized(), ) Dari.repository.addEntry(entry) - Dari.postMessageNotification(handlerName, MessageDirection.WEB_TO_APP, tag) + Dari.postMessageNotification(handlerName, entry.displayName, MessageDirection.WEB_TO_APP, tag) } override fun onWebToAppResponse( @@ -59,7 +73,20 @@ class DefaultDariInterceptor( } } - override fun onAppToWebRequest(handlerName: String, requestId: String?, data: String?, fireAndForget: Boolean?) { + override fun onAppToWebRequest( + handlerName: String, + requestId: String?, + data: String?, + fireAndForget: Boolean?, + ) = onAppToWebRequest(handlerName, null, requestId, data, fireAndForget) + + override fun onAppToWebRequest( + handlerName: String, + displayName: String?, + requestId: String?, + data: String?, + fireAndForget: Boolean?, + ) { val (truncatedData, wasTruncated) = MessageEntry.truncateIfNeeded(data, maxContentLength) val resolvedAsSuccess = fireAndForget ?: Dari.config.fireAndForget val entry = MessageEntry( @@ -70,9 +97,10 @@ class DefaultDariInterceptor( requestData = truncatedData, requestDataTruncated = wasTruncated, status = if (resolvedAsSuccess) MessageStatus.SUCCESS else MessageStatus.IN_PROGRESS, + displayName = displayName.normalized(), ) Dari.repository.addEntry(entry) - Dari.postMessageNotification(handlerName, MessageDirection.APP_TO_WEB, tag) + Dari.postMessageNotification(handlerName, entry.displayName, MessageDirection.APP_TO_WEB, tag) } override fun onAppToWebResponse(requestId: String?, isSuccess: Boolean, responseData: String?) { @@ -95,6 +123,14 @@ class DefaultDariInterceptor( requestId: String?, requestData: ByteArray, fireAndForget: Boolean?, + ) = onWebToAppProtobufRequest(handlerName, null, requestId, requestData, fireAndForget) + + override fun onWebToAppProtobufRequest( + handlerName: String, + displayName: String?, + requestId: String?, + requestData: ByteArray, + fireAndForget: Boolean?, ) { val rendered = renderProtobuf( payload = requestData, @@ -112,9 +148,10 @@ class DefaultDariInterceptor( requestDataTruncated = rendered.wasTruncated, requestPayloadMetadata = rendered.metadata, status = if (resolvedAsSuccess) MessageStatus.SUCCESS else MessageStatus.IN_PROGRESS, + displayName = displayName.normalized(), ) Dari.repository.addEntry(entry) - Dari.postMessageNotification(handlerName, MessageDirection.WEB_TO_APP, tag) + Dari.postMessageNotification(handlerName, entry.displayName, MessageDirection.WEB_TO_APP, tag) } override fun onWebToAppProtobufResponse( @@ -147,6 +184,14 @@ class DefaultDariInterceptor( requestId: String?, requestData: ByteArray, fireAndForget: Boolean?, + ) = onAppToWebProtobufRequest(handlerName, null, requestId, requestData, fireAndForget) + + override fun onAppToWebProtobufRequest( + handlerName: String, + displayName: String?, + requestId: String?, + requestData: ByteArray, + fireAndForget: Boolean?, ) { val rendered = renderProtobuf( payload = requestData, @@ -164,9 +209,10 @@ class DefaultDariInterceptor( requestDataTruncated = rendered.wasTruncated, requestPayloadMetadata = rendered.metadata, status = if (resolvedAsSuccess) MessageStatus.SUCCESS else MessageStatus.IN_PROGRESS, + displayName = displayName.normalized(), ) Dari.repository.addEntry(entry) - Dari.postMessageNotification(handlerName, MessageDirection.APP_TO_WEB, tag) + Dari.postMessageNotification(handlerName, entry.displayName, MessageDirection.APP_TO_WEB, tag) } override fun onAppToWebProtobufResponse( @@ -207,3 +253,5 @@ class DefaultDariInterceptor( ) } } + +private fun String?.normalized(): String? = this?.takeIf { it.isNotBlank() } diff --git a/dari/src/main/kotlin/com/easyhooon/dari/notification/DariNotification.kt b/dari/src/main/kotlin/com/easyhooon/dari/notification/DariNotification.kt index aec2fa8..8412679 100644 --- a/dari/src/main/kotlin/com/easyhooon/dari/notification/DariNotification.kt +++ b/dari/src/main/kotlin/com/easyhooon/dari/notification/DariNotification.kt @@ -9,6 +9,7 @@ import android.content.Intent import androidx.core.app.NotificationCompat import com.easyhooon.dari.MessageDirection import com.easyhooon.dari.R +import com.easyhooon.dari.handlerLabel import com.easyhooon.dari.ui.DariActivity /** @@ -54,12 +55,17 @@ internal class DariNotification(private val context: Context) { /** * Adds a new bridge message to the notification. */ - fun postMessage(handlerName: String, direction: MessageDirection, @Suppress("UNUSED_PARAMETER") tag: String? = null) { + fun postMessage( + handlerName: String, + displayName: String?, + direction: MessageDirection, + @Suppress("UNUSED_PARAMETER") tag: String? = null, + ) { val directionLabel = when (direction) { MessageDirection.WEB_TO_APP -> "W\u2192A" MessageDirection.APP_TO_WEB -> "A\u2192W" } - val line = "$directionLabel $handlerName" + val line = "$directionLabel ${handlerLabel(handlerName, displayName)}" if (recentLines.size >= MAX_LINES) { recentLines.removeFirst() diff --git a/dari/src/main/kotlin/com/easyhooon/dari/ui/DariActivity.kt b/dari/src/main/kotlin/com/easyhooon/dari/ui/DariActivity.kt index 2108590..ecc1483 100644 --- a/dari/src/main/kotlin/com/easyhooon/dari/ui/DariActivity.kt +++ b/dari/src/main/kotlin/com/easyhooon/dari/ui/DariActivity.kt @@ -68,6 +68,7 @@ import com.easyhooon.dari.MessageEntry import com.easyhooon.dari.MessageStatus import com.easyhooon.dari.export.DariExporter import com.easyhooon.dari.export.ExportFormat +import com.easyhooon.dari.matchesHandlerQuery import androidx.compose.foundation.isSystemInDarkTheme import com.easyhooon.dari.ui.components.MessageListItem import com.easyhooon.dari.ui.components.SettingsBottomSheet @@ -185,7 +186,7 @@ class DariActivity : ComponentActivity() { val filteredEntries = entries.reversed().filter { entry -> val matchesSearch = searchQuery.isBlank() || - entry.handlerName.contains(searchQuery, ignoreCase = true) + entry.matchesHandlerQuery(searchQuery) val matchesTag = selectedTag == null || entry.tag == selectedTag val matchesStatus = selectedStatus == null || entry.status == selectedStatus matchesSearch && matchesTag && matchesStatus 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 19b34f4..c78304a 100644 --- a/dari/src/main/kotlin/com/easyhooon/dari/ui/DariDetailActivity.kt +++ b/dari/src/main/kotlin/com/easyhooon/dari/ui/DariDetailActivity.kt @@ -51,6 +51,7 @@ 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.handlerLabel import com.easyhooon.dari.MessagePayloadMetadata import com.easyhooon.dari.RawPayloadFormatter import com.easyhooon.dari.export.DariExporter @@ -133,7 +134,9 @@ class DariDetailActivity : ComponentActivity() { Scaffold( topBar = { TopAppBar( - title = { Text(entry?.handlerName ?: "Detail") }, + title = { + Text(entry?.let { handlerLabel(it.handlerName, it.displayName) } ?: "Detail") + }, navigationIcon = { IconButton(onClick = { finish() }) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") @@ -286,6 +289,9 @@ private fun OverviewTab(entry: MessageEntry) { } OverviewRow("Handler", entry.handlerName) + entry.displayName?.takeIf { it.isNotBlank() }?.let { + OverviewRow("Display name", it) + } OverviewRow("Direction", direction) OverviewRow("Status", entry.status.name) OverviewRow("Tag", entry.tag ?: "-") diff --git a/dari/src/main/kotlin/com/easyhooon/dari/ui/components/MessageListItem.kt b/dari/src/main/kotlin/com/easyhooon/dari/ui/components/MessageListItem.kt index 96ae212..24efefa 100644 --- a/dari/src/main/kotlin/com/easyhooon/dari/ui/components/MessageListItem.kt +++ b/dari/src/main/kotlin/com/easyhooon/dari/ui/components/MessageListItem.kt @@ -24,6 +24,7 @@ import androidx.compose.ui.unit.sp import com.easyhooon.dari.MessageDirection import com.easyhooon.dari.MessageEntry import com.easyhooon.dari.MessageStatus +import com.easyhooon.dari.handlerLabel import com.easyhooon.dari.ui.theme.Blue500 import com.easyhooon.dari.ui.theme.BlueGrey400 import com.easyhooon.dari.ui.theme.Green500 @@ -67,7 +68,7 @@ internal fun MessageListItem( Column(modifier = Modifier.weight(1f)) { Row(verticalAlignment = Alignment.CenterVertically) { Text( - text = entry.handlerName, + text = handlerLabel(entry.handlerName, entry.displayName), style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium, maxLines = 1, diff --git a/dari/src/test/kotlin/com/easyhooon/dari/HandlerPresentationTest.kt b/dari/src/test/kotlin/com/easyhooon/dari/HandlerPresentationTest.kt new file mode 100644 index 0000000..09ebf6c --- /dev/null +++ b/dari/src/test/kotlin/com/easyhooon/dari/HandlerPresentationTest.kt @@ -0,0 +1,39 @@ +package com.easyhooon.dari + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class HandlerPresentationTest { + @Test + fun `handler label includes a non-blank display name`() { + assertEquals("N:4-1(오늘의 건강목표)", handlerLabel("N:4-1", "오늘의 건강목표")) + } + + @Test + fun `handler label keeps existing behavior for blank display name`() { + assertEquals("N:4-1", handlerLabel("N:4-1", " ")) + } + + @Test + fun `handler search matches the display name`() { + val entry = MessageEntry( + handlerName = "N:4-1", + direction = MessageDirection.WEB_TO_APP, + displayName = "오늘의 건강목표", + ) + + assertTrue(entry.matchesHandlerQuery("건강목표")) + } + + @Test + fun `handler search still matches the stable handler name`() { + val entry = MessageEntry( + handlerName = "N:4-1", + direction = MessageDirection.WEB_TO_APP, + displayName = "오늘의 건강목표", + ) + + assertTrue(entry.matchesHandlerQuery("n:4")) + } +} 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 index 9e163b1..b023f87 100644 --- a/dari/src/test/kotlin/com/easyhooon/dari/data/local/MessageEntityTest.kt +++ b/dari/src/test/kotlin/com/easyhooon/dari/data/local/MessageEntityTest.kt @@ -10,6 +10,21 @@ import org.junit.Assert.assertEquals import org.junit.Test class MessageEntityTest { + @Test + fun `display name survives entity round trip`() { + val entry = MessageEntry( + requestId = "request-1", + handlerName = "N:4-1", + direction = MessageDirection.WEB_TO_APP, + displayName = "오늘의 건강목표", + ) + + val restored = entry.toEntity().copy(id = 7).toMessageEntry() + + assertEquals("N:4-1", restored.handlerName) + assertEquals("오늘의 건강목표", restored.displayName) + } + @Test fun `protobuf metadata survives entity round trip`() { val metadata = MessagePayloadMetadata( 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 547af93..8f93703 100644 --- a/dari/src/test/kotlin/com/easyhooon/dari/export/DariExporterTest.kt +++ b/dari/src/test/kotlin/com/easyhooon/dari/export/DariExporterTest.kt @@ -52,6 +52,15 @@ class DariExporterTest { assertTrue(text.contains("Handler: showToast")) } + @Test + fun `formatSingleEntry contains display name as a separate field`() { + val text = DariExporter.formatSingleEntry( + createEntry(handlerName = "N:4-1").copy(displayName = "오늘의 건강목표"), + ) + + assertTrue(text.contains("Display name: 오늘의 건강목표")) + } + @Test fun `formatSingleEntry contains direction for WEB_TO_APP`() { val text = DariExporter.formatSingleEntry(createEntry(direction = MessageDirection.WEB_TO_APP)) @@ -185,6 +194,7 @@ class DariExporterTest { ) assertTrue(jsonString.contains("handler_name")) + assertTrue(jsonString.contains("display_name")) assertTrue(jsonString.contains("request_id")) assertTrue(jsonString.contains("request_data")) assertTrue(jsonString.contains("response_data")) @@ -211,6 +221,7 @@ class DariExporterTest { val parsed = Json.parseToJsonElement(jsonString).jsonArray[0].jsonObject assertTrue(parsed.containsKey("request_data")) + assertTrue(parsed.containsKey("display_name")) assertTrue(parsed.containsKey("response_data")) assertTrue(parsed.containsKey("response_timestamp")) assertTrue(parsed.containsKey("duration_ms")) 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 5a1f10a..80f429f 100644 --- a/dari/src/test/kotlin/com/easyhooon/dari/export/ExportableMessageTest.kt +++ b/dari/src/test/kotlin/com/easyhooon/dari/export/ExportableMessageTest.kt @@ -82,6 +82,16 @@ class ExportableMessageTest { assertEquals("PaymentWebView", entry.toExportable().tag) } + @Test + fun `toExportable preserves display name separately from handler name`() { + val exportable = createEntry(handlerName = "N:4-1") + .copy(displayName = "오늘의 건강목표") + .toExportable() + + assertEquals("N:4-1", exportable.handlerName) + assertEquals("오늘의 건강목표", exportable.displayName) + } + @Test fun `toExportable handles null optional fields`() { val entry = createEntry( diff --git a/documentation/content/docs/interceptor.mdx b/documentation/content/docs/interceptor.mdx index f5b3aea..ddd2c1a 100644 --- a/documentation/content/docs/interceptor.mdx +++ b/documentation/content/docs/interceptor.mdx @@ -16,6 +16,14 @@ interface DariInterceptor { fireAndForget: Boolean? = null, ) + fun onWebToAppRequest( + handlerName: String, + displayName: String?, + requestId: String?, + requestData: String?, + fireAndForget: Boolean? = null, + ) + fun onWebToAppResponse( handlerName: String, requestId: String?, @@ -30,6 +38,14 @@ interface DariInterceptor { fireAndForget: Boolean? = null, ) + fun onAppToWebRequest( + handlerName: String, + displayName: String?, + requestId: String?, + data: String?, + fireAndForget: Boolean? = null, + ) + fun onAppToWebResponse( requestId: String?, isSuccess: Boolean, @@ -84,6 +100,21 @@ Called when the native side pushes a message to JavaScript. Called when JavaScript responds to a native-initiated message. +## Optional display names + +Use the request overload with `displayName` when a stable handler identifier needs a human-readable label: + +```kotlin +interceptor?.onWebToAppRequest( + handlerName = "N:4-1", + displayName = "Today's health goal", + requestId = requestId, + requestData = payload, +) +``` + +Dari stores and exports both values separately, searches both, and renders `N:4-1(Today's health goal)` in the message list and notification. Blank display names behave like `null`. The same overload is available for App-to-Web and protobuf requests; existing signatures remain supported. + ## Fire-and-Forget One-way calls that never receive a response can be marked so they immediately resolve to `SUCCESS`: diff --git a/documentation/content/docs/ko/interceptor.mdx b/documentation/content/docs/ko/interceptor.mdx index 51c22f3..d12f528 100644 --- a/documentation/content/docs/ko/interceptor.mdx +++ b/documentation/content/docs/ko/interceptor.mdx @@ -16,6 +16,14 @@ interface DariInterceptor { fireAndForget: Boolean? = null, ) + fun onWebToAppRequest( + handlerName: String, + displayName: String?, + requestId: String?, + requestData: String?, + fireAndForget: Boolean? = null, + ) + fun onWebToAppResponse( handlerName: String, requestId: String?, @@ -30,6 +38,14 @@ interface DariInterceptor { fireAndForget: Boolean? = null, ) + fun onAppToWebRequest( + handlerName: String, + displayName: String?, + requestId: String?, + data: String?, + fireAndForget: Boolean? = null, + ) + fun onAppToWebResponse( requestId: String?, isSuccess: Boolean, @@ -84,6 +100,21 @@ JavaScript가 네이티브 앱으로 메시지를 보낼 때 호출합니다. JavaScript가 네이티브 앱의 요청에 응답할 때 호출합니다. +## 선택적 표시 이름 + +안정적인 핸들러 식별자에 읽기 쉬운 이름을 함께 표시하려면 `displayName`이 있는 요청 overload를 사용합니다: + +```kotlin +interceptor?.onWebToAppRequest( + handlerName = "N:4-1", + displayName = "오늘의 건강목표", + requestId = requestId, + requestData = payload, +) +``` + +Dari는 두 값을 분리하여 저장·내보내고 검색 시 두 값 모두를 확인합니다. 목록과 알림에는 `N:4-1(오늘의 건강목표)`로 표시됩니다. 공백 표시 이름은 `null`처럼 처리합니다. App-to-Web 및 protobuf 요청에도 같은 overload를 사용할 수 있으며 기존 시그니처도 계속 지원합니다. + ## Fire-and-Forget (단방향 호출) 응답을 받지 않는 단방향 호출은 즉시 `SUCCESS`로 처리되도록 설정할 수 있습니다: diff --git a/documentation/content/docs/ko/protobuf.mdx b/documentation/content/docs/ko/protobuf.mdx index ede7399..74e060f 100644 --- a/documentation/content/docs/ko/protobuf.mdx +++ b/documentation/content/docs/ko/protobuf.mdx @@ -65,6 +65,7 @@ val interceptor = Dari.createInterceptor( ```kotlin interceptor?.onWebToAppProtobufRequest( handlerName = "createOrder", + displayName = "주문 생성", requestId = requestId, requestData = requestBytes, ) @@ -81,7 +82,7 @@ interceptor?.onWebToAppProtobufResponse( ) ``` -요청과 응답을 연결하려면 동일하고 안정적인 `requestId`를 사용합니다. 단독 메시지 또는 fire-and-forget인 경우에만 `null`을 전달합니다. +요청과 응답을 연결하려면 동일하고 안정적인 `requestId`를 사용합니다. 단독 메시지 또는 fire-and-forget인 경우에만 `null`을 전달합니다. `displayName`은 선택 사항이며 안정적인 `handlerName`과 분리해 저장됩니다. ## 방향별 메서드 diff --git a/documentation/content/docs/protobuf.mdx b/documentation/content/docs/protobuf.mdx index acf64a9..698edb0 100644 --- a/documentation/content/docs/protobuf.mdx +++ b/documentation/content/docs/protobuf.mdx @@ -65,6 +65,7 @@ Call Dari at the same request and response boundaries already used by the bridge ```kotlin interceptor?.onWebToAppProtobufRequest( handlerName = "createOrder", + displayName = "Create order", requestId = requestId, requestData = requestBytes, ) @@ -81,7 +82,7 @@ interceptor?.onWebToAppProtobufResponse( ) ``` -Use a stable `requestId` to pair a request and response. Pass `null` only for standalone or fire-and-forget messages. +Use a stable `requestId` to pair a request and response. Pass `null` only for standalone or fire-and-forget messages. `displayName` is optional and is stored separately from the stable `handlerName`. ## Direction and method mapping