fix(chat): DM realtime 정리 누락을 보정한다

This commit is contained in:
2026-06-11 12:05:45 +09:00
parent 841ed5f6f8
commit a6d8cd54f3
2 changed files with 94 additions and 2 deletions

View File

@@ -1,5 +1,7 @@
package kr.co.vividnext.sodalive.v2.main.chat.dm package kr.co.vividnext.sodalive.v2.main.chat.dm
import android.os.Handler
import android.os.Looper
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import com.orhanobut.logger.Logger import com.orhanobut.logger.Logger
@@ -47,6 +49,7 @@ class DmChatRoomViewModel(
private var isDisconnecting: Boolean = false private var isDisconnecting: Boolean = false
private var reconnectDisposable: Disposable? = null private var reconnectDisposable: Disposable? = null
private var localMessageSequence: Long = 0L private var localMessageSequence: Long = 0L
private val mainHandler = Handler(Looper.getMainLooper())
private val _chatRoomStateLiveData = MutableLiveData<DmChatRoomUiState>() private val _chatRoomStateLiveData = MutableLiveData<DmChatRoomUiState>()
val chatRoomStateLiveData: LiveData<DmChatRoomUiState> val chatRoomStateLiveData: LiveData<DmChatRoomUiState>
@@ -252,7 +255,19 @@ class DmChatRoomViewModel(
} }
private fun scheduleRealtimeCallback(action: () -> Unit) { private fun scheduleRealtimeCallback(action: () -> Unit) {
compositeDisposable.add(AndroidSchedulers.mainThread().scheduleDirect(action)) if (Looper.myLooper() == Looper.getMainLooper()) {
action()
} else {
mainHandler.post { action() }
}
}
override fun onCleared() {
mainHandler.removeCallbacksAndMessages(null)
reconnectDisposable?.dispose()
reconnectDisposable = null
repository.cancelRealtime()
super.onCleared()
} }
private fun createRoomAndOpen(creatorId: Long) { private fun createRoomAndOpen(creatorId: Long) {

View File

@@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.v2.main.chat.dm
import android.app.Application import android.app.Application
import android.content.Context import android.content.Context
import android.os.Looper
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.lifecycle.Observer import androidx.lifecycle.Observer
import androidx.test.core.app.ApplicationProvider import androidx.test.core.app.ApplicationProvider
@@ -33,8 +34,11 @@ import org.junit.Assert.assertTrue
import org.junit.Before import org.junit.Before
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
import org.robolectric.Shadows.shadowOf
import org.robolectric.RobolectricTestRunner import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config import org.robolectric.annotation.Config
import java.lang.reflect.Method
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
@RunWith(RobolectricTestRunner::class) @RunWith(RobolectricTestRunner::class)
@@ -386,6 +390,22 @@ class DmChatRoomViewModelTest {
assertEquals(listOf("실시간"), state.messages.map { it.textMessage }) assertEquals(listOf("실시간"), state.messages.map { it.textMessage })
} }
@Test
fun `realtime message callback은 완료된 Disposable을 누적하지 않는다`() {
api.enqueueOpenSuccess(openResponse(roomId = 10L))
viewModel.enter(roomId = 10L, creatorId = 0L)
viewModel.connectRealtime()
val beforeCallbackSize = viewModel.compositeDisposable.size()
realtimeClient.listener?.onMessage(message(messageId = 3L, textMessage = "실시간1"))
realtimeClient.listener?.onMessage(message(messageId = 4L, textMessage = "실시간2"))
realtimeClient.listener?.onMessage(message(messageId = 5L, textMessage = "실시간3"))
assertEquals(beforeCallbackSize, viewModel.compositeDisposable.size())
val state = viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content
assertEquals(listOf(3L, 4L, 5L), state.messages.map { it.messageId })
}
@Test @Test
fun `realtime listener callback은 main thread scheduler로 상태를 갱신한다`() { fun `realtime listener callback은 main thread scheduler로 상태를 갱신한다`() {
val source = projectFile( val source = projectFile(
@@ -393,7 +413,8 @@ class DmChatRoomViewModelTest {
).readText() ).readText()
assertTrue(source.contains("scheduleRealtimeCallback")) assertTrue(source.contains("scheduleRealtimeCallback"))
assertTrue(source.contains("AndroidSchedulers.mainThread().scheduleDirect")) assertTrue(source.contains("Looper.myLooper() == Looper.getMainLooper()"))
assertTrue(source.contains("mainHandler.post { action() }"))
assertTrue(source.contains("scheduleRealtimeCallback { syncLatestMessagesAfterReconnect(token = token) }")) assertTrue(source.contains("scheduleRealtimeCallback { syncLatestMessagesAfterReconnect(token = token) }"))
assertTrue(source.contains("scheduleRealtimeCallback { onRealtimeMessage(message) }")) assertTrue(source.contains("scheduleRealtimeCallback { onRealtimeMessage(message) }"))
} }
@@ -532,6 +553,56 @@ class DmChatRoomViewModelTest {
assertEquals(listOf(1L), state.messages.map { it.messageId }) assertEquals(listOf(1L), state.messages.map { it.messageId })
} }
@Test
fun `onCleared는 realtime 연결과 disposable을 정리한다`() {
val pendingDisconnect = SingleSubject.create<ApiResponse<Boolean>>()
api.enqueueOpenSuccess(openResponse(roomId = 10L))
api.enqueueDisconnect(pendingDisconnect)
viewModel.enter(roomId = 10L, creatorId = 0L)
viewModel.connectRealtime()
viewModel.disconnectRealtime()
viewModel.invokeOnCleared()
assertEquals(2, realtimeClient.cancelCalls)
assertTrue(viewModel.compositeDisposable.isDisposed)
assertTrue(pendingDisconnect.hasObservers().not())
}
@Test
fun `onCleared는 예약된 realtime 재연결을 취소한다`() {
api.enqueueOpenSuccess(openResponse(roomId = 10L))
viewModel.enter(roomId = 10L, creatorId = 0L)
viewModel.connectRealtime()
realtimeClient.listener?.onFailure(IllegalStateException("network"))
viewModel.invokeOnCleared()
reconnectScheduler.advanceTimeBy(3L, TimeUnit.SECONDS)
assertEquals(listOf(RealtimeConnectCall("test-token", 10L)), realtimeClient.connectCalls)
assertEquals(1, realtimeClient.cancelCalls)
}
@Test
fun `onCleared는 예약된 main handler callback 실행을 막는다`() {
val postedLatch = CountDownLatch(1)
api.enqueueOpenSuccess(openResponse(roomId = 10L))
viewModel.enter(roomId = 10L, creatorId = 0L)
viewModel.connectRealtime()
Thread {
realtimeClient.listener?.onMessage(message(messageId = 99L, textMessage = "제거 대상"))
postedLatch.countDown()
}.start()
assertTrue(postedLatch.await(2, TimeUnit.SECONDS))
viewModel.invokeOnCleared()
shadowOf(Looper.getMainLooper()).idle()
val state = viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content
assertTrue(state.messages.isEmpty())
}
private fun projectFile(relativePath: String): java.io.File { private fun projectFile(relativePath: String): java.io.File {
val candidates = listOf(java.io.File(relativePath), java.io.File("../$relativePath")) val candidates = listOf(java.io.File(relativePath), java.io.File("../$relativePath"))
return candidates.firstOrNull { it.exists() } return candidates.firstOrNull { it.exists() }
@@ -595,6 +666,12 @@ class DmChatRoomViewModelTest {
removeObserver(observer) removeObserver(observer)
return value return value
} }
fun DmChatRoomViewModel.invokeOnCleared() {
val method: Method = DmChatRoomViewModel::class.java.getDeclaredMethod("onCleared")
method.isAccessible = true
method.invoke(this)
}
} }
} }