fix(dm): 실시간 연결 상태 처리를 보강한다
This commit is contained in:
+106
-21
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user