fix(dm): 실시간 연결 상태 처리를 보강한다

This commit is contained in:
2026-07-11 22:03:03 +09:00
parent 62007862a5
commit 30f7bd7556
2 changed files with 416 additions and 36 deletions

View File

@@ -9,6 +9,8 @@ import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Scheduler import io.reactivex.rxjava3.core.Scheduler
import io.reactivex.rxjava3.disposables.Disposable import io.reactivex.rxjava3.disposables.Disposable
import io.reactivex.rxjava3.schedulers.Schedulers import io.reactivex.rxjava3.schedulers.Schedulers
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.common.SodaLiveApplicationHolder
import kr.co.vividnext.sodalive.base.BaseViewModel import kr.co.vividnext.sodalive.base.BaseViewModel
import kr.co.vividnext.sodalive.common.ApiResponse import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.common.SharedPreferenceManager import kr.co.vividnext.sodalive.common.SharedPreferenceManager
@@ -26,6 +28,7 @@ import kr.co.vividnext.sodalive.v2.main.chat.dm.model.mergeByMessageId
import kr.co.vividnext.sodalive.v2.main.chat.dm.model.sortByCreatedAtAndMessageId import kr.co.vividnext.sodalive.v2.main.chat.dm.model.sortByCreatedAtAndMessageId
import kr.co.vividnext.sodalive.v2.main.chat.dm.model.toUiItem import kr.co.vividnext.sodalive.v2.main.chat.dm.model.toUiItem
import kr.co.vividnext.sodalive.v2.main.chat.dm.model.toUiItems import kr.co.vividnext.sodalive.v2.main.chat.dm.model.toUiItems
import java.util.UUID
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
class DmChatRoomViewModel( class DmChatRoomViewModel(
@@ -43,6 +46,7 @@ class DmChatRoomViewModel(
private var isLoadingOlder: Boolean = false private var isLoadingOlder: Boolean = false
private var isRealtimeJoining: Boolean = false private var isRealtimeJoining: Boolean = false
private var isRealtimeConnected: Boolean = false private var isRealtimeConnected: Boolean = false
private var joinErrorRetryCount: Int = 0
private var shouldReconnectRealtime: Boolean = false private var shouldReconnectRealtime: Boolean = false
private var currentAuthToken: String = "" private var currentAuthToken: String = ""
private var currentRealtimeToken: String = "" private var currentRealtimeToken: String = ""
@@ -51,7 +55,6 @@ class DmChatRoomViewModel(
private var heartbeatPingDisposable: Disposable? = null private var heartbeatPingDisposable: Disposable? = null
private var heartbeatTimeoutDisposable: Disposable? = null private var heartbeatTimeoutDisposable: Disposable? = null
private var localMessageSequence: Long = 0L private var localMessageSequence: Long = 0L
private var requestSequence: Long = 0L
private val pendingRequestLocalIds = mutableMapOf<String, String>() private val pendingRequestLocalIds = mutableMapOf<String, String>()
private val recentFailedRequestLocalIds = mutableMapOf<String, String>() private val recentFailedRequestLocalIds = mutableMapOf<String, String>()
private val pendingTimeoutDisposables = mutableMapOf<String, Disposable>() private val pendingTimeoutDisposables = mutableMapOf<String, Disposable>()
@@ -73,6 +76,10 @@ class DmChatRoomViewModel(
val roomOpenedEventLiveData: LiveData<DmChatEvent<Boolean>> val roomOpenedEventLiveData: LiveData<DmChatEvent<Boolean>>
get() = _roomOpenedEventLiveData get() = _roomOpenedEventLiveData
private val _isRealtimeConnectingLiveData = MutableLiveData(false)
val isRealtimeConnectingLiveData: LiveData<Boolean>
get() = _isRealtimeConnectingLiveData
fun enter(roomId: Long, creatorId: Long) { fun enter(roomId: Long, creatorId: Long) {
when { when {
roomId > 0L -> openRoom(roomId) roomId > 0L -> openRoom(roomId)
@@ -108,9 +115,9 @@ class DmChatRoomViewModel(
) )
} }
fun sendText(text: String) { fun sendText(text: String): Boolean {
val trimmed = text.trim() val trimmed = text.trim()
if (trimmed.isBlank() || currentRoomId <= 0L) return if (trimmed.isBlank() || currentRoomId <= 0L || !isRealtimeConnected) return false
val localId = nextLocalId() val localId = nextLocalId()
val requestId = nextRequestId() val requestId = nextRequestId()
@@ -131,13 +138,14 @@ class DmChatRoomViewModel(
emitContent() emitContent()
sendLocalMessage(requestId = requestId, text = trimmed) sendLocalMessage(requestId = requestId, text = trimmed)
return true
} }
fun retry(localId: String) { fun retry(localId: String) {
val failedItem = currentMessages.firstOrNull { val failedItem = currentMessages.firstOrNull {
it.localId == localId && it.status == DmChatMessageStatus.FAILED it.localId == localId && it.status == DmChatMessageStatus.FAILED
} ?: return } ?: return
if (currentRoomId <= 0L) return if (currentRoomId <= 0L || !isRealtimeConnected) return
val requestId = nextRequestId() val requestId = nextRequestId()
removeRecentFailedRequests(localId) removeRecentFailedRequests(localId)
@@ -207,6 +215,7 @@ class DmChatRoomViewModel(
currentRealtimeRoomId = roomId currentRealtimeRoomId = roomId
isRealtimeJoining = true isRealtimeJoining = true
isRealtimeConnected = false isRealtimeConnected = false
setRealtimeConnecting(true)
shouldReconnectRealtime = true shouldReconnectRealtime = true
reconnectDisposable?.dispose() reconnectDisposable?.dispose()
reconnectDisposable = null reconnectDisposable = null
@@ -219,15 +228,12 @@ class DmChatRoomViewModel(
override fun onFailure(throwable: Throwable) { override fun onFailure(throwable: Throwable) {
scheduleRealtimeCallback { scheduleRealtimeCallback {
isRealtimeJoining = false handleRealtimeDisconnect(throwable)
isRealtimeConnected = false
throwable.message?.let { Logger.e(it) }
scheduleRealtimeReconnect()
} }
} }
} }
) )
repository.sendJoinRoom(roomId) repository.sendJoinRoom(roomId = roomId, requestId = nextRequestId())
} }
fun leaveRealtime() { fun leaveRealtime() {
@@ -237,15 +243,20 @@ class DmChatRoomViewModel(
(isRealtimeJoining || isRealtimeConnected || currentRealtimeToken.isNotEmpty()) (isRealtimeJoining || isRealtimeConnected || currentRealtimeToken.isNotEmpty())
if (!hasActiveSocket) return if (!hasActiveSocket) return
val shouldSendLeave = isRealtimeConnected
shouldReconnectRealtime = false shouldReconnectRealtime = false
stopHeartbeat() stopHeartbeat()
currentRealtimeToken = "" currentRealtimeToken = ""
currentRealtimeRoomId = 0L currentRealtimeRoomId = 0L
isRealtimeJoining = false isRealtimeJoining = false
isRealtimeConnected = false isRealtimeConnected = false
joinErrorRetryCount = 0
setRealtimeConnecting(false)
reconnectDisposable?.dispose() reconnectDisposable?.dispose()
reconnectDisposable = null reconnectDisposable = null
repository.sendLeaveRoom(roomId) if (shouldSendLeave) {
repository.sendLeaveRoom(roomId = roomId, requestId = nextRequestId())
}
repository.closeSocket() repository.closeSocket()
} }
@@ -282,6 +293,8 @@ class DmChatRoomViewModel(
currentRealtimeRoomId = 0L currentRealtimeRoomId = 0L
isRealtimeJoining = false isRealtimeJoining = false
isRealtimeConnected = false isRealtimeConnected = false
joinErrorRetryCount = 0
setRealtimeConnecting(false)
repository.closeSocket() repository.closeSocket()
super.onCleared() super.onCleared()
} }
@@ -344,6 +357,7 @@ class DmChatRoomViewModel(
return return
} }
joinErrorRetryCount = 0
currentRoomId = data.roomId currentRoomId = data.roomId
opponentNickname = data.opponentNickname opponentNickname = data.opponentNickname
opponentProfileImageUrl = data.opponentProfileImageUrl opponentProfileImageUrl = data.opponentProfileImageUrl
@@ -375,17 +389,70 @@ class DmChatRoomViewModel(
private fun handleSocketEvent(event: DmChatSocketEvent, token: String) { private fun handleSocketEvent(event: DmChatSocketEvent, token: String) {
when (event) { when (event) {
DmChatSocketEvent.Joined -> { is DmChatSocketEvent.Joined -> {
if (!isCurrentRoomEvent(event.roomId)) return
joinErrorRetryCount = 0
isRealtimeJoining = false isRealtimeJoining = false
isRealtimeConnected = true isRealtimeConnected = true
setRealtimeConnecting(false)
startHeartbeat() startHeartbeat()
syncLatestMessagesAfterReconnect(token = token) syncLatestMessagesAfterReconnect(token = token)
} }
is DmChatSocketEvent.Message -> handleRealtimeMessage(event.requestId, event.message) is DmChatSocketEvent.Message -> {
is DmChatSocketEvent.SendAck -> handleSendAck(event.requestId, event.message) if (!isRealtimeConnected || !isStrictCurrentRoomEvent(event.roomId)) return
is DmChatSocketEvent.Error -> event.requestId?.let { markPendingMessageFailed(it) } handleRealtimeMessage(event.requestId, event.message)
DmChatSocketEvent.Pong -> clearHeartbeatTimeout()
} }
is DmChatSocketEvent.SendAck -> {
if (!isRealtimeConnected || !isStrictCurrentRoomEvent(event.roomId)) return
handleSendAck(event.requestId, event.message)
}
is DmChatSocketEvent.Error -> {
if (isRealtimeJoining && !isRealtimeConnected) {
handleJoinErrorBeforeJoined()
return
}
if (!isRealtimeConnected || !isCurrentRoomEvent(event.roomId)) return
event.requestId?.let { markPendingMessageFailed(it) }
}
is DmChatSocketEvent.Pong -> {
if (!isRealtimeConnected || !isCurrentRoomEvent(event.roomId)) return
clearHeartbeatTimeout()
}
}
}
private fun isCurrentRoomEvent(roomId: Long?): Boolean = roomId == null || roomId == currentRoomId
private fun isStrictCurrentRoomEvent(roomId: Long?): Boolean = roomId == currentRoomId
private fun handleJoinErrorBeforeJoined() {
isRealtimeJoining = false
isRealtimeConnected = false
stopHeartbeat()
repository.closeSocket()
if (joinErrorRetryCount < MAX_JOIN_ERROR_RETRY_COUNT) {
joinErrorRetryCount += 1
setRealtimeConnecting(true)
scheduleRealtimeReconnect()
return
}
failRealtimeRoomEntry()
}
private fun failRealtimeRoomEntry() {
shouldReconnectRealtime = false
joinErrorRetryCount = 0
reconnectDisposable?.dispose()
reconnectDisposable = null
currentRealtimeToken = ""
currentRealtimeRoomId = 0L
isRealtimeJoining = false
isRealtimeConnected = false
setRealtimeConnecting(false)
repository.closeSocket()
showError(SodaLiveApplicationHolder.get().getString(R.string.screen_dm_chat_room_enter_failed))
_finishEventLiveData.value = true
} }
private fun startHeartbeat() { private fun startHeartbeat() {
@@ -399,7 +466,11 @@ class DmChatRoomViewModel(
connectRealtime(token = latestToken) connectRealtime(token = latestToken)
return@scheduleRealtimeCallback return@scheduleRealtimeCallback
} }
if (repository.sendPing()) scheduleHeartbeatTimeout() if (repository.sendPing(roomId = currentRoomId, requestId = nextRequestId())) {
scheduleHeartbeatTimeout()
} else {
handleRealtimeDisconnect(IllegalStateException("WebSocket PING send failed"))
}
} }
}, },
HEARTBEAT_INTERVAL_MILLIS, HEARTBEAT_INTERVAL_MILLIS,
@@ -416,6 +487,7 @@ class DmChatRoomViewModel(
if (!isRealtimeConnected || !shouldReconnectRealtime) return@scheduleRealtimeCallback if (!isRealtimeConnected || !shouldReconnectRealtime) return@scheduleRealtimeCallback
isRealtimeJoining = false isRealtimeJoining = false
isRealtimeConnected = false isRealtimeConnected = false
setRealtimeConnecting(false)
stopHeartbeat() stopHeartbeat()
repository.closeSocket() repository.closeSocket()
scheduleRealtimeReconnect() scheduleRealtimeReconnect()
@@ -426,6 +498,16 @@ class DmChatRoomViewModel(
).also { compositeDisposable.add(it) } ).also { compositeDisposable.add(it) }
} }
private fun handleRealtimeDisconnect(throwable: Throwable) {
isRealtimeJoining = false
isRealtimeConnected = false
setRealtimeConnecting(false)
stopHeartbeat()
repository.closeSocket()
throwable.message?.let { Logger.e(it) }
scheduleRealtimeReconnect()
}
private fun stopHeartbeat() { private fun stopHeartbeat() {
heartbeatPingDisposable?.dispose() heartbeatPingDisposable?.dispose()
heartbeatPingDisposable = null heartbeatPingDisposable = null
@@ -437,6 +519,11 @@ class DmChatRoomViewModel(
heartbeatTimeoutDisposable = null heartbeatTimeoutDisposable = null
} }
private fun setRealtimeConnecting(isConnecting: Boolean) {
if (_isRealtimeConnectingLiveData.value == isConnecting) return
_isRealtimeConnectingLiveData.value = isConnecting
}
private fun handleSendAck(requestId: String, message: DmChatMessageResponse) { private fun handleSendAck(requestId: String, message: DmChatMessageResponse) {
val localId = pendingRequestLocalIds.remove(requestId) val localId = pendingRequestLocalIds.remove(requestId)
?: recentFailedRequestLocalIds.remove(requestId) ?: recentFailedRequestLocalIds.remove(requestId)
@@ -522,10 +609,7 @@ class DmChatRoomViewModel(
return "local-$localMessageSequence" return "local-$localMessageSequence"
} }
private fun nextRequestId(): String { private fun nextRequestId(): String = UUID.randomUUID().toString()
requestSequence += 1L
return "request-$requestSequence"
}
private fun authToken(): String { private fun authToken(): String {
val token = tokenProvider() val token = tokenProvider()
@@ -540,9 +624,10 @@ class DmChatRoomViewModel(
private companion object { private companion object {
const val RECONNECT_DELAY_MILLIS = 3_000L const val RECONNECT_DELAY_MILLIS = 3_000L
const val SEND_ACK_TIMEOUT_MILLIS = 10_000L const val SEND_ACK_TIMEOUT_MILLIS = 15_000L
const val HEARTBEAT_INTERVAL_MILLIS = 30_000L const val HEARTBEAT_INTERVAL_MILLIS = 30_000L
const val HEARTBEAT_TIMEOUT_MILLIS = 10_000L const val HEARTBEAT_TIMEOUT_MILLIS = 10_000L
const val MAX_JOIN_ERROR_RETRY_COUNT = 3
} }
} }

View File

@@ -14,8 +14,10 @@ import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.plugins.RxJavaPlugins import io.reactivex.rxjava3.plugins.RxJavaPlugins
import io.reactivex.rxjava3.schedulers.Schedulers import io.reactivex.rxjava3.schedulers.Schedulers
import io.reactivex.rxjava3.schedulers.TestScheduler import io.reactivex.rxjava3.schedulers.TestScheduler
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.common.ApiResponse import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.common.SharedPreferenceManager import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.common.SodaLiveApplicationHolder
import kr.co.vividnext.sodalive.v2.main.chat.dm.data.CreateDmChatRoomRequest import kr.co.vividnext.sodalive.v2.main.chat.dm.data.CreateDmChatRoomRequest
import kr.co.vividnext.sodalive.v2.main.chat.dm.data.CreateDmChatRoomResponse import kr.co.vividnext.sodalive.v2.main.chat.dm.data.CreateDmChatRoomResponse
import kr.co.vividnext.sodalive.v2.main.chat.dm.data.DmChatApi import kr.co.vividnext.sodalive.v2.main.chat.dm.data.DmChatApi
@@ -24,9 +26,12 @@ import kr.co.vividnext.sodalive.v2.main.chat.dm.data.DmChatMessagesPageResponse
import kr.co.vividnext.sodalive.v2.main.chat.dm.data.DmChatRepository import kr.co.vividnext.sodalive.v2.main.chat.dm.data.DmChatRepository
import kr.co.vividnext.sodalive.v2.main.chat.dm.data.DmChatRoomOpenResponse import kr.co.vividnext.sodalive.v2.main.chat.dm.data.DmChatRoomOpenResponse
import kr.co.vividnext.sodalive.v2.main.chat.dm.data.DmChatSocketClient import kr.co.vividnext.sodalive.v2.main.chat.dm.data.DmChatSocketClient
import kr.co.vividnext.sodalive.v2.main.chat.dm.data.SendDmVoiceMessageResponse
import kr.co.vividnext.sodalive.v2.main.chat.dm.model.DmChatMessageStatus import kr.co.vividnext.sodalive.v2.main.chat.dm.model.DmChatMessageStatus
import kr.co.vividnext.sodalive.v2.main.chat.dm.model.DmChatRoomUiState import kr.co.vividnext.sodalive.v2.main.chat.dm.model.DmChatRoomUiState
import okhttp3.MultipartBody
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.RequestBody
import okhttp3.Request import okhttp3.Request
import okhttp3.WebSocket import okhttp3.WebSocket
import okhttp3.WebSocketListener import okhttp3.WebSocketListener
@@ -62,6 +67,7 @@ class DmChatRoomViewModelTest {
setImmediateRxSchedulers() setImmediateRxSchedulers()
SharedPreferenceManager.resetForTest() SharedPreferenceManager.resetForTest()
SharedPreferenceManager.init(context) SharedPreferenceManager.init(context)
SodaLiveApplicationHolder.init(context as Application)
SharedPreferenceManager.token = "test-token" SharedPreferenceManager.token = "test-token"
token = "test-token" token = "test-token"
api = FakeDmChatApi() api = FakeDmChatApi()
@@ -159,25 +165,147 @@ class DmChatRoomViewModelTest {
api.enqueueOpenSuccess(openResponse(roomId = 10L)) api.enqueueOpenSuccess(openResponse(roomId = 10L))
viewModel.enter(roomId = 10L, creatorId = 0L) viewModel.enter(roomId = 10L, creatorId = 0L)
viewModel.sendText(" ") val accepted = viewModel.sendText(" ")
assertEquals(false, accepted)
assertTrue(socketFactory.webSocket.sentTexts.isEmpty()) assertTrue(socketFactory.webSocket.sentTexts.isEmpty())
val state = viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content val state = viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content
assertTrue(state.messages.isEmpty()) assertTrue(state.messages.isEmpty())
} }
@Test
fun `JOINED 전에는 텍스트를 전송하지 않는다`() {
api.enqueueOpenSuccess(openResponse(roomId = 10L))
viewModel.enter(roomId = 10L, creatorId = 0L)
viewModel.connectRealtime()
val accepted = viewModel.sendText("안녕")
assertEquals(false, accepted)
assertEquals(listOf("JOIN_ROOM"), socketFactory.webSocket.sentTexts.map { it.type() })
val state = viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content
assertTrue(state.messages.isEmpty())
}
@Test
fun `텍스트 전송 requestId는 UUID 형식이다`() {
api.enqueueOpenSuccess(openResponse(roomId = 10L))
viewModel.enter(roomId = 10L, creatorId = 0L)
viewModel.connectRealtime()
socketFactory.emitJoined()
val accepted = viewModel.sendText("안녕")
assertEquals(true, accepted)
val requestId = (viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content)
.messages.single().requestId!!
assertTrue(requestId.matches(Regex("[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}")))
}
@Test
fun `pending timeout은 15초 기준으로 실패 처리한다`() {
api.enqueueOpenSuccess(openResponse(roomId = 10L))
viewModel.enter(roomId = 10L, creatorId = 0L)
viewModel.connectRealtime()
socketFactory.emitJoined()
viewModel.sendText("timeout")
reconnectScheduler.advanceTimeBy(10L, TimeUnit.SECONDS)
shadowOf(Looper.getMainLooper()).idle()
var state = viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content
assertEquals(DmChatMessageStatus.SENDING, state.messages.single().status)
reconnectScheduler.advanceTimeBy(5L, TimeUnit.SECONDS)
shadowOf(Looper.getMainLooper()).idle()
state = viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content
assertEquals(DmChatMessageStatus.FAILED, state.messages.single().status)
}
@Test
fun `현재 roomId와 다른 socket MESSAGE와 SEND_ACK는 무시한다`() {
api.enqueueOpenSuccess(openResponse(roomId = 10L))
viewModel.enter(roomId = 10L, creatorId = 0L)
viewModel.connectRealtime()
socketFactory.emitJoined()
viewModel.sendText("안녕")
val requestId = (viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content)
.messages.single().requestId!!
socketFactory.emitMessage(roomId = 11L, message = message(messageId = 80L, textMessage = "다른 방"))
socketFactory.emitAck(
roomId = 11L,
requestId = requestId,
message = message(messageId = 81L, mine = true, textMessage = "다른 ACK")
)
val state = viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content
assertEquals(listOf(null), state.messages.map { it.messageId })
assertEquals(listOf("안녕"), state.messages.map { it.textMessage })
assertEquals(listOf(DmChatMessageStatus.SENDING), state.messages.map { it.status })
}
@Test
fun `roomId가 null인 socket MESSAGE와 SEND_ACK는 무시한다`() {
api.enqueueOpenSuccess(openResponse(roomId = 10L))
viewModel.enter(roomId = 10L, creatorId = 0L)
viewModel.connectRealtime()
socketFactory.emitJoined()
viewModel.sendText("안녕")
val requestId = (viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content)
.messages.single().requestId!!
socketFactory.emitMessage(roomId = null, message = message(messageId = 80L, textMessage = "malformed"))
socketFactory.emitAck(
roomId = null,
requestId = requestId,
message = message(messageId = 81L, mine = true, textMessage = "malformed ACK")
)
val state = viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content
assertEquals(listOf(null), state.messages.map { it.messageId })
assertEquals(listOf(DmChatMessageStatus.SENDING), state.messages.map { it.status })
}
@Test
fun `JOINED 전 ERROR는 세 번 재시도 후 로딩 해제 오류 표시 종료를 수행한다`() {
api.enqueueOpenSuccess(openResponse(roomId = 10L))
viewModel.enter(roomId = 10L, creatorId = 0L)
viewModel.connectRealtime()
repeat(3) { retryIndex ->
socketFactory.emitJoinError(roomId = 0L)
assertEquals(true, viewModel.isRealtimeConnectingLiveData.requireValue())
reconnectScheduler.advanceTimeBy(3L, TimeUnit.SECONDS)
shadowOf(Looper.getMainLooper()).idle()
assertEquals(retryIndex + 2, socketFactory.connectCalls.size)
}
socketFactory.emitJoinError(roomId = 0L)
assertEquals(false, viewModel.isRealtimeConnectingLiveData.requireValue())
assertEquals(true, viewModel.finishEventLiveData.requireValue())
val state = viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Error
assertEquals(context.getString(R.string.screen_dm_chat_room_enter_failed), state.message)
val connectCountAfterFailure = socketFactory.connectCalls.size
reconnectScheduler.advanceTimeBy(3L, TimeUnit.SECONDS)
shadowOf(Looper.getMainLooper()).idle()
assertEquals(connectCountAfterFailure, socketFactory.connectCalls.size)
assertTrue(socketFactory.closeCount >= 4)
}
@Test @Test
fun `전송 직후 pending을 추가하고 성공 시 서버 메시지로 교체한다`() { fun `전송 직후 pending을 추가하고 성공 시 서버 메시지로 교체한다`() {
api.enqueueOpenSuccess(openResponse(roomId = 10L)) api.enqueueOpenSuccess(openResponse(roomId = 10L))
viewModel.enter(roomId = 10L, creatorId = 0L) viewModel.enter(roomId = 10L, creatorId = 0L)
viewModel.connectRealtime() viewModel.connectRealtime()
socketFactory.emitJoined()
viewModel.sendText(" 안녕 ") viewModel.sendText(" 안녕 ")
val sendingState = viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content val sendingState = viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content
val requestId = sendingState.messages.single().requestId!! val requestId = sendingState.messages.single().requestId!!
assertEquals("SEND_TEXT", socketFactory.webSocket.sentJsonAt(1).get("type").asString) assertEquals("SEND_TEXT", socketFactory.webSocket.sentJsonAt(1).get("type").asString)
assertEquals(requestId, socketFactory.webSocket.sentJsonAt(1).getAsJsonObject("payload").get("requestId").asString) assertEquals(requestId, socketFactory.webSocket.sentJsonAt(1).get("requestId").asString)
assertEquals(DmChatMessageStatus.SENDING, sendingState.messages.single().status) assertEquals(DmChatMessageStatus.SENDING, sendingState.messages.single().status)
assertEquals("안녕", sendingState.messages.single().textMessage) assertEquals("안녕", sendingState.messages.single().textMessage)
@@ -192,6 +320,7 @@ class DmChatRoomViewModelTest {
api.enqueueOpenSuccess(openResponse(roomId = 10L)) api.enqueueOpenSuccess(openResponse(roomId = 10L))
viewModel.enter(roomId = 10L, creatorId = 0L) viewModel.enter(roomId = 10L, creatorId = 0L)
viewModel.connectRealtime() viewModel.connectRealtime()
socketFactory.emitJoined()
viewModel.sendText("안녕") viewModel.sendText("안녕")
viewModel.sendText("안녕") viewModel.sendText("안녕")
@@ -206,6 +335,7 @@ class DmChatRoomViewModelTest {
api.enqueueOpenSuccess(openResponse(roomId = 10L)) api.enqueueOpenSuccess(openResponse(roomId = 10L))
viewModel.enter(roomId = 10L, creatorId = 0L) viewModel.enter(roomId = 10L, creatorId = 0L)
viewModel.connectRealtime() viewModel.connectRealtime()
socketFactory.emitJoined()
viewModel.sendText("첫번째") viewModel.sendText("첫번째")
viewModel.sendText("두번째") viewModel.sendText("두번째")
@@ -217,8 +347,8 @@ class DmChatRoomViewModelTest {
assertNotEquals(requestIds[0], requestIds[1]) assertNotEquals(requestIds[0], requestIds[1])
assertEquals("SEND_TEXT", socketFactory.webSocket.sentJsonAt(1).get("type").asString) assertEquals("SEND_TEXT", socketFactory.webSocket.sentJsonAt(1).get("type").asString)
assertEquals("SEND_TEXT", socketFactory.webSocket.sentJsonAt(2).get("type").asString) assertEquals("SEND_TEXT", socketFactory.webSocket.sentJsonAt(2).get("type").asString)
assertEquals(requestIds[0], socketFactory.webSocket.sentJsonAt(1).getAsJsonObject("payload").get("requestId").asString) assertEquals(requestIds[0], socketFactory.webSocket.sentJsonAt(1).get("requestId").asString)
assertEquals(requestIds[1], socketFactory.webSocket.sentJsonAt(2).getAsJsonObject("payload").get("requestId").asString) assertEquals(requestIds[1], socketFactory.webSocket.sentJsonAt(2).get("requestId").asString)
} }
@Test @Test
@@ -226,6 +356,7 @@ class DmChatRoomViewModelTest {
api.enqueueOpenSuccess(openResponse(roomId = 10L)) api.enqueueOpenSuccess(openResponse(roomId = 10L))
viewModel.enter(roomId = 10L, creatorId = 0L) viewModel.enter(roomId = 10L, creatorId = 0L)
viewModel.connectRealtime() viewModel.connectRealtime()
socketFactory.emitJoined()
viewModel.sendText("첫번째") viewModel.sendText("첫번째")
viewModel.sendText("두번째") viewModel.sendText("두번째")
val pendingState = viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content val pendingState = viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content
@@ -244,6 +375,7 @@ class DmChatRoomViewModelTest {
api.enqueueOpenSuccess(openResponse(roomId = 10L)) api.enqueueOpenSuccess(openResponse(roomId = 10L))
viewModel.enter(roomId = 10L, creatorId = 0L) viewModel.enter(roomId = 10L, creatorId = 0L)
viewModel.connectRealtime() viewModel.connectRealtime()
socketFactory.emitJoined()
viewModel.sendText("첫번째") viewModel.sendText("첫번째")
viewModel.sendText("두번째") viewModel.sendText("두번째")
val pendingState = viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content val pendingState = viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content
@@ -260,13 +392,14 @@ class DmChatRoomViewModelTest {
api.enqueueOpenSuccess(openResponse(roomId = 10L)) api.enqueueOpenSuccess(openResponse(roomId = 10L))
viewModel.enter(roomId = 10L, creatorId = 0L) viewModel.enter(roomId = 10L, creatorId = 0L)
viewModel.connectRealtime() viewModel.connectRealtime()
socketFactory.emitJoined()
viewModel.sendText("첫번째") viewModel.sendText("첫번째")
viewModel.sendText("두번째") viewModel.sendText("두번째")
val pendingState = viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content val pendingState = viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content
val secondRequestId = pendingState.messages[1].requestId!! val secondRequestId = pendingState.messages[1].requestId!!
socketFactory.emitAck(secondRequestId, message(messageId = 31L, mine = true, textMessage = "두번째")) socketFactory.emitAck(secondRequestId, message(messageId = 31L, mine = true, textMessage = "두번째"))
reconnectScheduler.advanceTimeBy(10L, TimeUnit.SECONDS) reconnectScheduler.advanceTimeBy(15L, TimeUnit.SECONDS)
shadowOf(Looper.getMainLooper()).idle() shadowOf(Looper.getMainLooper()).idle()
val state = viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content val state = viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content
@@ -279,6 +412,7 @@ class DmChatRoomViewModelTest {
api.enqueueOpenSuccess(openResponse(roomId = 10L)) api.enqueueOpenSuccess(openResponse(roomId = 10L))
viewModel.enter(roomId = 10L, creatorId = 0L) viewModel.enter(roomId = 10L, creatorId = 0L)
viewModel.connectRealtime() viewModel.connectRealtime()
socketFactory.emitJoined()
viewModel.sendText("안녕") viewModel.sendText("안녕")
val requestId = (viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content) val requestId = (viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content)
.messages.single().requestId!! .messages.single().requestId!!
@@ -296,6 +430,7 @@ class DmChatRoomViewModelTest {
api.enqueueOpenSuccess(openResponse(roomId = 10L)) api.enqueueOpenSuccess(openResponse(roomId = 10L))
viewModel.enter(roomId = 10L, creatorId = 0L) viewModel.enter(roomId = 10L, creatorId = 0L)
viewModel.connectRealtime() viewModel.connectRealtime()
socketFactory.emitJoined()
viewModel.sendText("재시도") viewModel.sendText("재시도")
val failedRequestId = (viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content) val failedRequestId = (viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content)
.messages.single().requestId!! .messages.single().requestId!!
@@ -311,7 +446,7 @@ class DmChatRoomViewModelTest {
assertEquals(DmChatMessageStatus.SENDING, retryItem.status) assertEquals(DmChatMessageStatus.SENDING, retryItem.status)
assertEquals( assertEquals(
retryItem.requestId, retryItem.requestId,
socketFactory.webSocket.sentJsonAt(2).getAsJsonObject("payload").get("requestId").asString socketFactory.webSocket.sentJsonAt(2).get("requestId").asString
) )
} }
@@ -320,6 +455,7 @@ class DmChatRoomViewModelTest {
api.enqueueOpenSuccess(openResponse(roomId = 10L)) api.enqueueOpenSuccess(openResponse(roomId = 10L))
viewModel.enter(roomId = 10L, creatorId = 0L) viewModel.enter(roomId = 10L, creatorId = 0L)
viewModel.connectRealtime() viewModel.connectRealtime()
socketFactory.emitJoined()
socketFactory.webSocket.sendResult = false socketFactory.webSocket.sendResult = false
viewModel.sendText("안녕") viewModel.sendText("안녕")
@@ -343,6 +479,7 @@ class DmChatRoomViewModelTest {
api.enqueueOpenSuccess(openResponse(roomId = 10L)) api.enqueueOpenSuccess(openResponse(roomId = 10L))
viewModel.enter(roomId = 10L, creatorId = 0L) viewModel.enter(roomId = 10L, creatorId = 0L)
viewModel.connectRealtime() viewModel.connectRealtime()
socketFactory.emitJoined()
socketFactory.webSocket.sendResult = false socketFactory.webSocket.sendResult = false
viewModel.sendText("안녕") viewModel.sendText("안녕")
val failedState = viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content val failedState = viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content
@@ -378,6 +515,7 @@ class DmChatRoomViewModelTest {
api.enqueueOpenSuccess(openResponse(roomId = 10L)) api.enqueueOpenSuccess(openResponse(roomId = 10L))
viewModel.enter(roomId = 10L, creatorId = 0L) viewModel.enter(roomId = 10L, creatorId = 0L)
viewModel.connectRealtime() viewModel.connectRealtime()
socketFactory.emitJoined()
viewModel.sendText("안녕") viewModel.sendText("안녕")
val requestId = (viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content) val requestId = (viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content)
@@ -395,6 +533,7 @@ class DmChatRoomViewModelTest {
api.enqueueOpenSuccess(openResponse(roomId = 10L)) api.enqueueOpenSuccess(openResponse(roomId = 10L))
viewModel.enter(roomId = 10L, creatorId = 0L) viewModel.enter(roomId = 10L, creatorId = 0L)
viewModel.connectRealtime() viewModel.connectRealtime()
socketFactory.emitJoined()
viewModel.sendText("선도착") viewModel.sendText("선도착")
val pendingItem = (viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content) val pendingItem = (viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content)
.messages.single() .messages.single()
@@ -414,6 +553,7 @@ class DmChatRoomViewModelTest {
api.enqueueOpenSuccess(openResponse(roomId = 10L)) api.enqueueOpenSuccess(openResponse(roomId = 10L))
viewModel.enter(roomId = 10L, creatorId = 0L) viewModel.enter(roomId = 10L, creatorId = 0L)
viewModel.connectRealtime() viewModel.connectRealtime()
socketFactory.emitJoined()
viewModel.sendText("중복 방지") viewModel.sendText("중복 방지")
val requestId = (viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content) val requestId = (viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content)
.messages.single().requestId!! .messages.single().requestId!!
@@ -431,12 +571,13 @@ class DmChatRoomViewModelTest {
api.enqueueOpenSuccess(openResponse(roomId = 10L)) api.enqueueOpenSuccess(openResponse(roomId = 10L))
viewModel.enter(roomId = 10L, creatorId = 0L) viewModel.enter(roomId = 10L, creatorId = 0L)
viewModel.connectRealtime() viewModel.connectRealtime()
socketFactory.emitJoined()
viewModel.sendText("늦은 ACK") viewModel.sendText("늦은 ACK")
val pendingItem = (viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content) val pendingItem = (viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content)
.messages.single() .messages.single()
val requestId = pendingItem.requestId!! val requestId = pendingItem.requestId!!
reconnectScheduler.advanceTimeBy(10L, TimeUnit.SECONDS) reconnectScheduler.advanceTimeBy(15L, TimeUnit.SECONDS)
shadowOf(Looper.getMainLooper()).idle() shadowOf(Looper.getMainLooper()).idle()
socketFactory.emitAck(requestId, message(messageId = 62L, mine = true, textMessage = "늦은 ACK")) socketFactory.emitAck(requestId, message(messageId = 62L, mine = true, textMessage = "늦은 ACK"))
@@ -451,12 +592,13 @@ class DmChatRoomViewModelTest {
api.enqueueOpenSuccess(openResponse(roomId = 10L)) api.enqueueOpenSuccess(openResponse(roomId = 10L))
viewModel.enter(roomId = 10L, creatorId = 0L) viewModel.enter(roomId = 10L, creatorId = 0L)
viewModel.connectRealtime() viewModel.connectRealtime()
socketFactory.emitJoined()
viewModel.sendText("재시도 ACK") viewModel.sendText("재시도 ACK")
val firstState = viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content val firstState = viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content
val failedItem = firstState.messages.single() val failedItem = firstState.messages.single()
val firstRequestId = failedItem.requestId!! val firstRequestId = failedItem.requestId!!
reconnectScheduler.advanceTimeBy(10L, TimeUnit.SECONDS) reconnectScheduler.advanceTimeBy(15L, TimeUnit.SECONDS)
shadowOf(Looper.getMainLooper()).idle() shadowOf(Looper.getMainLooper()).idle()
viewModel.retry(failedItem.localId!!) viewModel.retry(failedItem.localId!!)
val retryRequestId = (viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content) val retryRequestId = (viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content)
@@ -535,6 +677,36 @@ class DmChatRoomViewModelTest {
assertEquals(listOf(1L, 2L), state.messages.map { it.messageId }) assertEquals(listOf(1L, 2L), state.messages.map { it.messageId })
} }
@Test
fun `realtime 연결 중 상태는 JOINED 수신 전까지만 true이다`() {
api.enqueueOpenSuccess(openResponse(roomId = 10L))
viewModel.enter(roomId = 10L, creatorId = 0L)
viewModel.connectRealtime()
assertEquals(true, viewModel.isRealtimeConnectingLiveData.requireValue())
socketFactory.emitJoined()
assertEquals(false, viewModel.isRealtimeConnectingLiveData.requireValue())
}
@Test
fun `realtime 연결 실패와 leave는 연결 중 상태를 해제한다`() {
api.enqueueOpenSuccess(openResponse(roomId = 10L))
viewModel.enter(roomId = 10L, creatorId = 0L)
viewModel.connectRealtime()
socketFactory.emitFailure(IllegalStateException("network"))
assertEquals(false, viewModel.isRealtimeConnectingLiveData.requireValue())
viewModel.connectRealtime()
viewModel.leaveRealtime()
assertEquals(false, viewModel.isRealtimeConnectingLiveData.requireValue())
}
@Test @Test
fun `realtime 연결 중 중복 connect 요청은 무시한다`() { fun `realtime 연결 중 중복 connect 요청은 무시한다`() {
api.enqueueOpenSuccess(openResponse(roomId = 10L)) api.enqueueOpenSuccess(openResponse(roomId = 10L))
@@ -587,6 +759,7 @@ class DmChatRoomViewModelTest {
viewModel.enter(roomId = 10L, creatorId = 0L) viewModel.enter(roomId = 10L, creatorId = 0L)
viewModel.connectRealtime() viewModel.connectRealtime()
socketFactory.emitJoined()
socketFactory.emitMessage(message(messageId = 3L, textMessage = "실시간")) socketFactory.emitMessage(message(messageId = 3L, textMessage = "실시간"))
val state = viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content val state = viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content
@@ -599,6 +772,7 @@ class DmChatRoomViewModelTest {
api.enqueueOpenSuccess(openResponse(roomId = 10L)) api.enqueueOpenSuccess(openResponse(roomId = 10L))
viewModel.enter(roomId = 10L, creatorId = 0L) viewModel.enter(roomId = 10L, creatorId = 0L)
viewModel.connectRealtime() viewModel.connectRealtime()
socketFactory.emitJoined()
val beforeCallbackSize = viewModel.compositeDisposable.size() val beforeCallbackSize = viewModel.compositeDisposable.size()
socketFactory.emitMessage(message(messageId = 3L, textMessage = "실시간1")) socketFactory.emitMessage(message(messageId = 3L, textMessage = "실시간1"))
@@ -725,6 +899,7 @@ class DmChatRoomViewModelTest {
api.enqueueOpenSuccess(openResponse(roomId = 10L)) api.enqueueOpenSuccess(openResponse(roomId = 10L))
viewModel.enter(roomId = 10L, creatorId = 0L) viewModel.enter(roomId = 10L, creatorId = 0L)
viewModel.connectRealtime() viewModel.connectRealtime()
socketFactory.emitJoined()
viewModel.leaveRealtime() viewModel.leaveRealtime()
viewModel.leaveRealtime() viewModel.leaveRealtime()
@@ -773,6 +948,48 @@ class DmChatRoomViewModelTest {
assertEquals("JOIN_ROOM", socketFactory.webSocket.sentTexts.lastJson().get("type").asString) assertEquals("JOIN_ROOM", socketFactory.webSocket.sentTexts.lastJson().get("type").asString)
} }
@Test
fun `socket close callback은 foreground 상태에서 reconnect를 예약한다`() {
api.enqueueOpenSuccess(openResponse(roomId = 10L))
api.enqueueMessagesSuccess(messagesPage(messages = emptyList()))
viewModel.enter(roomId = 10L, creatorId = 0L)
viewModel.connectRealtime()
socketFactory.emitJoined()
socketFactory.emitClosed()
reconnectScheduler.advanceTimeBy(2999L, TimeUnit.MILLISECONDS)
assertEquals(false, viewModel.isRealtimeConnectedForTest())
assertEquals(1, socketFactory.closeCount)
assertEquals(1, socketFactory.connectCalls.size)
reconnectScheduler.advanceTimeBy(1L, TimeUnit.MILLISECONDS)
assertEquals(2, socketFactory.connectCalls.size)
assertEquals("JOIN_ROOM", socketFactory.webSocket.sentTexts.lastJson().get("type").asString)
}
@Test
fun `heartbeat PING 전송 실패는 timeout 대기 없이 socket close 후 reconnect를 예약한다`() {
api.enqueueOpenSuccess(openResponse(roomId = 10L))
api.enqueueMessagesSuccess(messagesPage(messages = emptyList()))
viewModel.enter(roomId = 10L, creatorId = 0L)
viewModel.connectRealtime()
socketFactory.emitJoined()
socketFactory.webSocket.sendResult = false
reconnectScheduler.advanceTimeBy(30L, TimeUnit.SECONDS)
reconnectScheduler.advanceTimeBy(2999L, TimeUnit.MILLISECONDS)
assertEquals(false, viewModel.isRealtimeConnectedForTest())
assertEquals(1, socketFactory.closeCount)
assertEquals(1, socketFactory.connectCalls.size)
reconnectScheduler.advanceTimeBy(1L, TimeUnit.MILLISECONDS)
assertEquals(2, socketFactory.connectCalls.size)
}
@Test @Test
fun `leave는 heartbeat timeout과 reconnect 예약을 취소한다`() { fun `leave는 heartbeat timeout과 reconnect 예약을 취소한다`() {
api.enqueueOpenSuccess(openResponse(roomId = 10L)) api.enqueueOpenSuccess(openResponse(roomId = 10L))
@@ -1031,8 +1248,47 @@ class FakeDmChatApi : DmChatApi {
limit: Int limit: Int
): Single<ApiResponse<DmChatMessagesPageResponse>> { ): Single<ApiResponse<DmChatMessagesPageResponse>> {
messagesCalls.add(MessagesCall(authHeader, roomId, cursor, limit)) messagesCalls.add(MessagesCall(authHeader, roomId, cursor, limit))
return messagesResponses.removeFirst() return if (messagesResponses.isEmpty()) {
Single.just(
ApiResponse(
success = true,
data = DmChatMessagesPageResponse(
messages = emptyList(),
hasMore = false,
nextCursor = null
)
)
)
} else {
messagesResponses.removeFirst()
} }
}
override fun sendDmVoiceMessage(
authHeader: String,
roomId: Long,
voiceMessageFile: MultipartBody.Part,
request: RequestBody
): Single<ApiResponse<SendDmVoiceMessageResponse>> = Single.just(
ApiResponse(
success = true,
data = SendDmVoiceMessageResponse(
message = DmChatMessageResponse(
messageId = 1L,
messageType = "VOICE",
mine = true,
createdAt = 1000L,
textMessage = null,
voiceMessageUrl = "https://example.com/voice.m4a",
senderId = 1L,
senderNickname = "",
senderProfileImageUrl = "https://example.com/me.png"
),
deliveredRealtime = true,
pushSent = false
)
)
)
} }
class FakeWebSocketFactory { class FakeWebSocketFactory {
@@ -1050,44 +1306,83 @@ class FakeWebSocketFactory {
} }
fun emitJoined() { fun emitJoined() {
webSocketListener?.onMessage(webSocket, "{\"type\":\"JOINED\",\"payload\":{}}") webSocketListener?.onMessage(
webSocket,
"{\"type\":\"JOINED\",\"requestId\":\"join-request\",\"roomId\":10,\"payload\":{}}"
)
} }
fun emitMessage(message: DmChatMessageResponse) { fun emitMessage(message: DmChatMessageResponse) {
val json = Gson().toJson(message) val json = Gson().toJson(message)
webSocketListener?.onMessage(webSocket, "{\"type\":\"MESSAGE\",\"payload\":{\"message\":$json}}") webSocketListener?.onMessage(webSocket, "{\"type\":\"MESSAGE\",\"requestId\":null,\"roomId\":10,\"payload\":$json}")
} }
fun emitMessage(requestId: String, message: DmChatMessageResponse) { fun emitMessage(requestId: String, message: DmChatMessageResponse) {
emitMessage(roomId = 10L, requestId = requestId, message = message)
}
fun emitMessage(roomId: Long?, message: DmChatMessageResponse) {
val json = Gson().toJson(message) val json = Gson().toJson(message)
webSocketListener?.onMessage( webSocketListener?.onMessage(
webSocket, webSocket,
"{\"type\":\"MESSAGE\",\"payload\":{\"requestId\":\"$requestId\",\"message\":$json}}" "{\"type\":\"MESSAGE\",\"requestId\":null,\"roomId\":$roomId,\"payload\":$json}"
)
}
fun emitMessage(roomId: Long, requestId: String, message: DmChatMessageResponse) {
val json = Gson().toJson(message)
webSocketListener?.onMessage(
webSocket,
"{\"type\":\"MESSAGE\",\"requestId\":\"$requestId\",\"roomId\":$roomId,\"payload\":$json}"
) )
} }
fun emitAck(requestId: String, message: DmChatMessageResponse) { fun emitAck(requestId: String, message: DmChatMessageResponse) {
emitAck(roomId = 10L, requestId = requestId, message = message)
}
fun emitAck(roomId: Long?, requestId: String, message: DmChatMessageResponse) {
val json = Gson().toJson(message) val json = Gson().toJson(message)
webSocketListener?.onMessage( webSocketListener?.onMessage(
webSocket, webSocket,
"{\"type\":\"SEND_ACK\",\"payload\":{\"requestId\":\"$requestId\",\"message\":$json}}" "{\"type\":\"SEND_ACK\",\"requestId\":\"$requestId\",\"roomId\":$roomId,\"payload\":$json}"
) )
} }
fun emitError(requestId: String) { fun emitError(requestId: String) {
val json = """
{"type":"ERROR","requestId":"$requestId","roomId":10,"payload":{"messageKey":"chat.message.send_failed"}}
""".trimIndent()
webSocketListener?.onMessage( webSocketListener?.onMessage(
webSocket, webSocket,
"{\"type\":\"ERROR\",\"payload\":{\"requestId\":\"$requestId\",\"code\":\"SEND_FAILED\",\"message\":\"failed\"}}" json
)
}
fun emitJoinError(roomId: Long?) {
val json = """
{"type":"ERROR","requestId":"join-request","roomId":$roomId,"payload":{"messageKey":"chat.room.invalid_access"}}
""".trimIndent()
webSocketListener?.onMessage(
webSocket,
json
) )
} }
fun emitPong() { fun emitPong() {
webSocketListener?.onMessage(webSocket, "{\"type\":\"PONG\",\"payload\":{}}") webSocketListener?.onMessage(
webSocket,
"{\"type\":\"PONG\",\"requestId\":\"ping-request\",\"roomId\":10,\"payload\":{}}"
)
} }
fun emitFailure(throwable: Throwable) { fun emitFailure(throwable: Throwable) {
webSocketListener?.onFailure(webSocket, throwable, null) webSocketListener?.onFailure(webSocket, throwable, null)
} }
fun emitClosed() {
webSocketListener?.onClosed(webSocket, 1001, "going away")
}
} }
class FakeWebSocket : WebSocket { class FakeWebSocket : WebSocket {