feat(dm): WebSocket envelope 계약을 보정한다

This commit is contained in:
2026-07-11 22:02:40 +09:00
parent 46c3a02655
commit 2786e45c75
6 changed files with 328 additions and 151 deletions
@@ -2,6 +2,9 @@ package kr.co.vividnext.sodalive.v2.main.chat.dm.data
import io.reactivex.rxjava3.core.Single
import kr.co.vividnext.sodalive.common.ApiResponse
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody
import okhttp3.RequestBody.Companion.toRequestBody
class DmChatRepository(
private val api: DmChatApi,
@@ -37,6 +40,17 @@ class DmChatRepository(
limit = limit
)
fun sendVoiceMessage(
token: String,
roomId: Long,
voiceMessageFile: MultipartBody.Part
): Single<ApiResponse<SendDmVoiceMessageResponse>> = api.sendDmVoiceMessage(
authHeader = bearer(token),
roomId = roomId,
voiceMessageFile = voiceMessageFile,
request = VOICE_REQUEST_JSON.toRequestBody(JSON_MEDIA_TYPE.toMediaType())
)
fun connectSocket(
token: String,
listener: DmChatSocketClient.Listener
@@ -44,9 +58,21 @@ class DmChatRepository(
socketClient?.connect(token = token, listener = listener)
}
fun sendJoinRoom(roomId: Long): Boolean = socketClient?.sendJoinRoom(roomId = roomId) ?: false
fun sendJoinRoom(
roomId: Long,
requestId: String
): Boolean = socketClient?.sendJoinRoom(
roomId = roomId,
requestId = requestId
) ?: false
fun sendLeaveRoom(roomId: Long): Boolean = socketClient?.sendLeaveRoom(roomId = roomId) ?: false
fun sendLeaveRoom(
roomId: Long,
requestId: String
): Boolean = socketClient?.sendLeaveRoom(
roomId = roomId,
requestId = requestId
) ?: false
fun sendSocketText(
roomId: Long,
@@ -58,7 +84,13 @@ class DmChatRepository(
textMessage = textMessage
) ?: false
fun sendPing(): Boolean = socketClient?.sendPing() ?: false
fun sendPing(
roomId: Long,
requestId: String
): Boolean = socketClient?.sendPing(
roomId = roomId,
requestId = requestId
) ?: false
fun closeSocket() {
socketClient?.close()
@@ -68,5 +100,7 @@ class DmChatRepository(
private companion object {
const val DEFAULT_LIMIT = 20
const val VOICE_REQUEST_JSON = "{\"recipientId\":null}"
const val JSON_MEDIA_TYPE = "application/json"
}
}
@@ -1,18 +1,22 @@
package kr.co.vividnext.sodalive.v2.main.chat.dm.data
import androidx.annotation.Keep
import com.google.gson.Gson
import com.google.gson.JsonObject
import com.google.gson.annotations.SerializedName
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import java.io.IOException
class DmChatSocketClient(
private val okHttpClient: OkHttpClient,
private val gson: Gson,
private val baseUrl: String,
private val webSocketFactory: (Request, WebSocketListener) -> WebSocket = okHttpClient::newWebSocket
private val webSocketFactory: (Request, WebSocketListener) -> WebSocket = okHttpClient::newWebSocket,
private val rawJsonLogger: (String) -> Unit = {}
) {
interface Listener {
fun onEvent(event: DmChatSocketEvent)
@@ -40,8 +44,11 @@ class DmChatSocketClient(
.header(HEADER_AUTHORIZATION, bearer(token))
.build()
val socketListener = object : WebSocketListener() {
private var closeNotified: Boolean = false
override fun onMessage(webSocket: WebSocket, text: String) {
if (webSocket != activeSocket) return
rawJsonLogger(text)
parser.parse(text)?.let { event -> this@DmChatSocketClient.listener?.onEvent(event) }
}
@@ -49,19 +56,39 @@ class DmChatSocketClient(
if (webSocket != activeSocket) return
this@DmChatSocketClient.listener?.onFailure(t)
}
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
notifySocketClosed(webSocket, code, reason)
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
notifySocketClosed(webSocket, code, reason)
}
private fun notifySocketClosed(webSocket: WebSocket, code: Int, reason: String) {
if (webSocket != activeSocket || closeNotified) return
closeNotified = true
this@DmChatSocketClient.listener?.onFailure(
IOException("WebSocket closed: code=$code reason=$reason")
)
}
}
webSocket = webSocketFactory(request, socketListener).also { activeSocket = it }
}
fun sendJoinRoom(roomId: Long): Boolean = send(
fun sendJoinRoom(roomId: Long, requestId: String): Boolean = send(
type = DmChatSocketClientType.JOIN_ROOM,
payload = DmChatSocketRoomPayload(roomId = roomId)
requestId = requestId,
roomId = roomId,
payload = JsonObject()
)
fun sendLeaveRoom(roomId: Long): Boolean = send(
fun sendLeaveRoom(roomId: Long, requestId: String): Boolean = send(
type = DmChatSocketClientType.LEAVE_ROOM,
payload = DmChatSocketRoomPayload(roomId = roomId)
requestId = requestId,
roomId = roomId,
payload = JsonObject()
)
fun sendText(
@@ -70,15 +97,15 @@ class DmChatSocketClient(
textMessage: String
): Boolean = send(
type = DmChatSocketClientType.SEND_TEXT,
payload = DmChatSocketSendTextPayload(
roomId = roomId,
requestId = requestId,
textMessage = textMessage
)
requestId = requestId,
roomId = roomId,
payload = DmChatSocketSendTextPayload(textMessage = textMessage)
)
fun sendPing(): Boolean = send(
fun sendPing(roomId: Long, requestId: String): Boolean = send(
type = DmChatSocketClientType.PING,
requestId = requestId,
roomId = roomId,
payload = JsonObject()
)
@@ -93,10 +120,21 @@ class DmChatSocketClient(
private fun send(
type: DmChatSocketClientType,
requestId: String,
roomId: Long,
payload: Any
): Boolean {
val socket = webSocket ?: return false
return socket.send(gson.toJson(DmChatSocketOutboundEnvelope(type = type.value, payload = payload)))
val rawJson = gson.toJson(
DmChatSocketOutboundEnvelope(
type = type.value,
requestId = requestId,
roomId = roomId,
payload = payload
)
)
rawJsonLogger(rawJson)
return socket.send(rawJson)
}
private fun socketUrl(): String = baseUrl
@@ -106,9 +144,12 @@ class DmChatSocketClient(
private fun bearer(token: String) = "Bearer $token"
@Keep
private data class DmChatSocketOutboundEnvelope(
val type: String,
val payload: Any
@SerializedName("type") val type: String,
@SerializedName("requestId") val requestId: String,
@SerializedName("roomId") val roomId: Long,
@SerializedName("payload") val payload: Any
)
private companion object {
@@ -9,38 +9,19 @@ import com.google.gson.annotations.SerializedName
@Keep
data class DmChatSocketEnvelope(
@SerializedName("type") val type: String,
@SerializedName("requestId") val requestId: String?,
@SerializedName("roomId") val roomId: Long?,
@SerializedName("payload") val payload: JsonObject?
)
@Keep
data class DmChatSocketRoomPayload(
@SerializedName("roomId") val roomId: Long
)
@Keep
data class DmChatSocketSendTextPayload(
@SerializedName("roomId") val roomId: Long,
@SerializedName("requestId") val requestId: String,
@SerializedName("textMessage") val textMessage: String
)
@Keep
data class DmChatSocketMessagePayload(
@SerializedName("requestId") val requestId: String?,
@SerializedName("message") val message: DmChatMessageResponse
)
@Keep
data class DmChatSocketSendAckPayload(
@SerializedName("requestId") val requestId: String,
@SerializedName("message") val message: DmChatMessageResponse
)
@Keep
data class DmChatSocketErrorPayload(
@SerializedName("requestId") val requestId: String?,
@SerializedName("code") val code: String?,
@SerializedName("message") val message: String?
@SerializedName("messageKey") val messageKey: String?
)
enum class DmChatSocketClientType(val value: String) {
@@ -51,32 +32,46 @@ enum class DmChatSocketClientType(val value: String) {
}
sealed class DmChatSocketEvent {
data object Joined : DmChatSocketEvent()
data class Joined(
val requestId: String?,
val roomId: Long?
) : DmChatSocketEvent()
data class Message(
val requestId: String?,
val roomId: Long?,
val message: DmChatMessageResponse
) : DmChatSocketEvent()
data class SendAck(
val requestId: String,
val roomId: Long?,
val message: DmChatMessageResponse
) : DmChatSocketEvent()
data class Error(
val requestId: String?,
val code: String?,
val message: String?
val roomId: Long?,
val messageKey: String?
) : DmChatSocketEvent()
data class Pong(
val requestId: String?,
val roomId: Long?
) : DmChatSocketEvent()
data object Pong : DmChatSocketEvent()
}
class DmChatSocketParser(private val gson: Gson) {
fun parse(text: String): DmChatSocketEvent? = try {
val envelope = gson.fromJson(text, DmChatSocketEnvelope::class.java)
when (envelope.type) {
TYPE_JOINED -> DmChatSocketEvent.Joined
TYPE_MESSAGE -> parseMessage(envelope.payload)
TYPE_SEND_ACK -> parseSendAck(envelope.payload)
TYPE_ERROR -> parseError(envelope.payload)
TYPE_PONG -> DmChatSocketEvent.Pong
TYPE_JOINED -> DmChatSocketEvent.Joined(
requestId = envelope.requestId,
roomId = envelope.roomId
)
TYPE_MESSAGE -> parseMessage(envelope)
TYPE_SEND_ACK -> parseSendAck(envelope)
TYPE_ERROR -> parseError(envelope)
TYPE_PONG -> DmChatSocketEvent.Pong(
requestId = envelope.requestId,
roomId = envelope.roomId
)
else -> null
}
} catch (e: JsonSyntaxException) {
@@ -87,28 +82,31 @@ class DmChatSocketParser(private val gson: Gson) {
null
}
private fun parseMessage(payload: JsonObject?): DmChatSocketEvent.Message? {
val messagePayload = gson.fromJson(payload, DmChatSocketMessagePayload::class.java) ?: return null
private fun parseMessage(envelope: DmChatSocketEnvelope): DmChatSocketEvent.Message? {
val message = gson.fromJson(envelope.payload, DmChatMessageResponse::class.java) ?: return null
return DmChatSocketEvent.Message(
requestId = messagePayload.requestId,
message = messagePayload.message
requestId = envelope.requestId,
roomId = envelope.roomId,
message = message
)
}
private fun parseSendAck(payload: JsonObject?): DmChatSocketEvent.SendAck? {
val ackPayload = gson.fromJson(payload, DmChatSocketSendAckPayload::class.java) ?: return null
private fun parseSendAck(envelope: DmChatSocketEnvelope): DmChatSocketEvent.SendAck? {
val requestId = envelope.requestId ?: return null
val message = gson.fromJson(envelope.payload, DmChatMessageResponse::class.java) ?: return null
return DmChatSocketEvent.SendAck(
requestId = ackPayload.requestId,
message = ackPayload.message
requestId = requestId,
roomId = envelope.roomId,
message = message
)
}
private fun parseError(payload: JsonObject?): DmChatSocketEvent.Error? {
val errorPayload = gson.fromJson(payload, DmChatSocketErrorPayload::class.java) ?: return null
private fun parseError(envelope: DmChatSocketEnvelope): DmChatSocketEvent.Error? {
val errorPayload = gson.fromJson(envelope.payload, DmChatSocketErrorPayload::class.java) ?: return null
return DmChatSocketEvent.Error(
requestId = errorPayload.requestId,
code = errorPayload.code,
message = errorPayload.message
requestId = envelope.requestId,
roomId = envelope.roomId,
messageKey = errorPayload.messageKey
)
}