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