From a6d8cd54f36942acbfac2c32193be524cda62326 Mon Sep 17 00:00:00 2001 From: klaus Date: Thu, 11 Jun 2026 12:05:45 +0900 Subject: [PATCH] =?UTF-8?q?fix(chat):=20DM=20realtime=20=EC=A0=95=EB=A6=AC?= =?UTF-8?q?=20=EB=88=84=EB=9D=BD=EC=9D=84=20=EB=B3=B4=EC=A0=95=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../v2/main/chat/dm/DmChatRoomViewModel.kt | 17 +++- .../main/chat/dm/DmChatRoomViewModelTest.kt | 79 ++++++++++++++++++- 2 files changed, 94 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomViewModel.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomViewModel.kt index 0f57361c..692fa16e 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomViewModel.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomViewModel.kt @@ -1,5 +1,7 @@ package kr.co.vividnext.sodalive.v2.main.chat.dm +import android.os.Handler +import android.os.Looper import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import com.orhanobut.logger.Logger @@ -47,6 +49,7 @@ class DmChatRoomViewModel( private var isDisconnecting: Boolean = false private var reconnectDisposable: Disposable? = null private var localMessageSequence: Long = 0L + private val mainHandler = Handler(Looper.getMainLooper()) private val _chatRoomStateLiveData = MutableLiveData() val chatRoomStateLiveData: LiveData @@ -252,7 +255,19 @@ class DmChatRoomViewModel( } 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) { diff --git a/app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomViewModelTest.kt b/app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomViewModelTest.kt index e0c96a89..4a298fcc 100644 --- a/app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomViewModelTest.kt +++ b/app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomViewModelTest.kt @@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.v2.main.chat.dm import android.app.Application import android.content.Context +import android.os.Looper import androidx.lifecycle.LiveData import androidx.lifecycle.Observer import androidx.test.core.app.ApplicationProvider @@ -33,8 +34,11 @@ import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import org.junit.runner.RunWith +import org.robolectric.Shadows.shadowOf import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config +import java.lang.reflect.Method +import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit @RunWith(RobolectricTestRunner::class) @@ -386,6 +390,22 @@ class DmChatRoomViewModelTest { 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 fun `realtime listener callback은 main thread scheduler로 상태를 갱신한다`() { val source = projectFile( @@ -393,7 +413,8 @@ class DmChatRoomViewModelTest { ).readText() 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 { onRealtimeMessage(message) }")) } @@ -532,6 +553,56 @@ class DmChatRoomViewModelTest { assertEquals(listOf(1L), state.messages.map { it.messageId }) } + @Test + fun `onCleared는 realtime 연결과 disposable을 정리한다`() { + val pendingDisconnect = SingleSubject.create>() + 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 { val candidates = listOf(java.io.File(relativePath), java.io.File("../$relativePath")) return candidates.firstOrNull { it.exists() } @@ -595,6 +666,12 @@ class DmChatRoomViewModelTest { removeObserver(observer) return value } + + fun DmChatRoomViewModel.invokeOnCleared() { + val method: Method = DmChatRoomViewModel::class.java.getDeclaredMethod("onCleared") + method.isAccessible = true + method.invoke(this) + } } }