fix(chat): DM realtime 정리 누락을 보정한다
This commit is contained in:
@@ -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) {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user