diff --git a/app/src/main/java/kr/co/vividnext/sodalive/live/room/LiveRoomActivity.kt b/app/src/main/java/kr/co/vividnext/sodalive/live/room/LiveRoomActivity.kt index c33f0862..f6ca7c1f 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/live/room/LiveRoomActivity.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/live/room/LiveRoomActivity.kt @@ -170,6 +170,7 @@ class LiveRoomActivity : BaseActivity(ActivityLiveRoomB private var isSpeakerMute = false private var isMicrophoneMute = false private var isSpeaker = false + private var hasKnownHostAbsence = false private var isCapturePrivacyMuted = false private var isScreenRecordingActive = false @@ -2304,13 +2305,26 @@ class LiveRoomActivity : BaseActivity(ActivityLiveRoomB override fun onUserOffline(uid: Int, reason: Int) { super.onUserOffline(uid, reason) Logger.e("onUserOffline - uid: $uid") - if (viewModel.isEqualToHostId(uid)) { + + val offlineAction = resolveLiveRoomOfflineAction( + isHostOffline = viewModel.isEqualToHostId(uid), + hasKnownHostAbsence = hasKnownHostAbsence + ) + + if (offlineAction.shouldMarkHostAbsence) { + hasKnownHostAbsence = true + } + + if (offlineAction.shouldFinishRoom) { handler.post { showToast(getString(R.string.screen_live_room_closed)) finish() } - } else { - viewModel.getRoomInfo(roomId) + return + } + + if (offlineAction.shouldRefreshRoomInfo) { + viewModel.getRoomInfo(roomId, suppressRoomNotFoundError = true) speakerListAdapter.muteSpeakers.remove(uid) } } @@ -2669,7 +2683,7 @@ class LiveRoomActivity : BaseActivity(ActivityLiveRoomB } } else if (eventType == RtmConstants.RtmPresenceEventType.REMOTE_LEAVE) { if (!viewModel.isEqualToHostId(memberId.toInt())) { - viewModel.getRoomInfo(roomId) + viewModel.getRoomInfo(roomId, suppressRoomNotFoundError = true) } } } diff --git a/app/src/main/java/kr/co/vividnext/sodalive/live/room/LiveRoomOfflineActionPolicy.kt b/app/src/main/java/kr/co/vividnext/sodalive/live/room/LiveRoomOfflineActionPolicy.kt new file mode 100644 index 00000000..47e67297 --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/live/room/LiveRoomOfflineActionPolicy.kt @@ -0,0 +1,34 @@ +package kr.co.vividnext.sodalive.live.room + +internal data class LiveRoomOfflineAction( + val shouldMarkHostAbsence: Boolean, + val shouldFinishRoom: Boolean, + val shouldRefreshRoomInfo: Boolean +) + +internal fun resolveLiveRoomOfflineAction( + isHostOffline: Boolean, + hasKnownHostAbsence: Boolean +): LiveRoomOfflineAction { + if (hasKnownHostAbsence) { + return LiveRoomOfflineAction( + shouldMarkHostAbsence = false, + shouldFinishRoom = false, + shouldRefreshRoomInfo = false + ) + } + + if (isHostOffline) { + return LiveRoomOfflineAction( + shouldMarkHostAbsence = true, + shouldFinishRoom = true, + shouldRefreshRoomInfo = false + ) + } + + return LiveRoomOfflineAction( + shouldMarkHostAbsence = false, + shouldFinishRoom = false, + shouldRefreshRoomInfo = true + ) +} diff --git a/app/src/main/java/kr/co/vividnext/sodalive/live/room/LiveRoomRoomInfoErrorPolicy.kt b/app/src/main/java/kr/co/vividnext/sodalive/live/room/LiveRoomRoomInfoErrorPolicy.kt new file mode 100644 index 00000000..31cf38d7 --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/live/room/LiveRoomRoomInfoErrorPolicy.kt @@ -0,0 +1,24 @@ +package kr.co.vividnext.sodalive.live.room + +private val ignorableLiveRoomNotFoundMessages = setOf( + "라이브 정보가 없습니다.", + "해당하는 라이브의 정보가 없습니다.", + "Live session information not found.", + "該当するライブの情報がありません。" +) + +internal fun shouldSuppressLiveRoomInfoError( + message: String?, + suppressRoomNotFoundError: Boolean +): Boolean { + if (!suppressRoomNotFoundError) { + return false + } + + val normalizedMessage = message?.trim().orEmpty() + if (normalizedMessage.isBlank()) { + return false + } + + return normalizedMessage in ignorableLiveRoomNotFoundMessages +} diff --git a/app/src/main/java/kr/co/vividnext/sodalive/live/room/LiveRoomViewModel.kt b/app/src/main/java/kr/co/vividnext/sodalive/live/room/LiveRoomViewModel.kt index de38bb04..1c790a86 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/live/room/LiveRoomViewModel.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/live/room/LiveRoomViewModel.kt @@ -232,7 +232,12 @@ class LiveRoomViewModel( ) } - fun getRoomInfo(roomId: Long, userId: Int = 0, onSuccess: (String) -> Unit = {}) { + fun getRoomInfo( + roomId: Long, + userId: Int = 0, + suppressRoomNotFoundError: Boolean = false, + onSuccess: (String) -> Unit = {} + ) { compositeDisposable.add( repository.getRoomInfo(roomId, "Bearer ${SharedPreferenceManager.token}") .subscribeOn(Schedulers.io()) @@ -266,6 +271,10 @@ class LiveRoomViewModel( onSuccess(nickname) } } else { + if (shouldSuppressLiveRoomInfoError(it.message, suppressRoomNotFoundError)) { + return@subscribe + } + if (it.message != null) { _toastLiveData.postValue(it.message) } else { diff --git a/app/src/test/java/kr/co/vividnext/sodalive/live/room/LiveRoomOfflineActionPolicyTest.kt b/app/src/test/java/kr/co/vividnext/sodalive/live/room/LiveRoomOfflineActionPolicyTest.kt new file mode 100644 index 00000000..fb76cf6b --- /dev/null +++ b/app/src/test/java/kr/co/vividnext/sodalive/live/room/LiveRoomOfflineActionPolicyTest.kt @@ -0,0 +1,44 @@ +package kr.co.vividnext.sodalive.live.room + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class LiveRoomOfflineActionPolicyTest { + + @Test + fun `방장 offline이 처음 감지되면 종료만 수행한다`() { + val action = resolveLiveRoomOfflineAction( + isHostOffline = true, + hasKnownHostAbsence = false + ) + + assertTrue(action.shouldMarkHostAbsence) + assertTrue(action.shouldFinishRoom) + assertFalse(action.shouldRefreshRoomInfo) + } + + @Test + fun `방장이 아직 남아있으면 비방장 offline에서 방 정보를 재조회한다`() { + val action = resolveLiveRoomOfflineAction( + isHostOffline = false, + hasKnownHostAbsence = false + ) + + assertFalse(action.shouldMarkHostAbsence) + assertFalse(action.shouldFinishRoom) + assertTrue(action.shouldRefreshRoomInfo) + } + + @Test + fun `방장 부재가 이미 확정되면 후속 offline에서는 아무 동작도 하지 않는다`() { + val action = resolveLiveRoomOfflineAction( + isHostOffline = false, + hasKnownHostAbsence = true + ) + + assertFalse(action.shouldMarkHostAbsence) + assertFalse(action.shouldFinishRoom) + assertFalse(action.shouldRefreshRoomInfo) + } +} diff --git a/app/src/test/java/kr/co/vividnext/sodalive/live/room/LiveRoomRoomInfoErrorPolicyTest.kt b/app/src/test/java/kr/co/vividnext/sodalive/live/room/LiveRoomRoomInfoErrorPolicyTest.kt new file mode 100644 index 00000000..3cbb8d12 --- /dev/null +++ b/app/src/test/java/kr/co/vividnext/sodalive/live/room/LiveRoomRoomInfoErrorPolicyTest.kt @@ -0,0 +1,38 @@ +package kr.co.vividnext.sodalive.live.room + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class LiveRoomRoomInfoErrorPolicyTest { + + @Test + fun `leave 재조회에서 라이브 정보가 없습니다 메시지는 숨긴다`() { + assertTrue( + shouldSuppressLiveRoomInfoError( + message = "라이브 정보가 없습니다.", + suppressRoomNotFoundError = true + ) + ) + } + + @Test + fun `suppress 플래그가 없으면 같은 메시지도 숨기지 않는다`() { + assertFalse( + shouldSuppressLiveRoomInfoError( + message = "라이브 정보가 없습니다.", + suppressRoomNotFoundError = false + ) + ) + } + + @Test + fun `관련 없는 오류 메시지는 그대로 노출한다`() { + assertFalse( + shouldSuppressLiveRoomInfoError( + message = "네트워크 오류가 발생했습니다.", + suppressRoomNotFoundError = true + ) + ) + } +} diff --git a/docs/20260413_라이브룸방장부재중복조회방지.md b/docs/20260413_라이브룸방장부재중복조회방지.md new file mode 100644 index 00000000..49e892c3 --- /dev/null +++ b/docs/20260413_라이브룸방장부재중복조회방지.md @@ -0,0 +1,47 @@ +# 20260413 라이브룸 방장 부재 중복 조회 방지 + +## 작업 체크리스트 +- [x] `LiveRoomActivity.onUserOffline`와 방장 부재 판별 흐름을 기준으로 중복 `getRoomInfo` 호출 조건을 확정한다. + QA: 방장 offline 감지 후 후속 user offline 콜백에서는 방 정보 재조회 조건이 false여야 한다. +- [x] 방장 부재가 확정된 뒤에는 추가 `getRoomInfo(roomId)`를 호출하지 않도록 최소 가드를 반영한다. + QA: 비방장 offline은 기존처럼 재조회하되, 방장 offline 이후에는 재조회가 차단되어야 한다. +- [x] 변경 파일 검증과 결과 기록을 남긴다. + QA: 관련 단위 테스트, `:app:testDebugUnitTest`, `:app:assembleDebug` 결과를 문서 하단에 기록한다. +- [x] leave/offline 신호로 발생한 `getRoomInfo`에서 종료 race의 room-not-found 메시지만 숨길 범위를 확정한다. + QA: leave/offline 재조회에서만 room-not-found suppress가 적용되고, 다른 `getRoomInfo` 실패는 기존처럼 노출되어야 한다. +- [x] 종료 race에서 내려오는 `라이브 정보가 없습니다.` 메시지를 사용자에게 노출하지 않도록 최소 변경을 반영한다. + QA: leave/offline 기반 `getRoomInfo`가 room-not-found를 받아도 토스트가 발생하지 않아야 한다. + +## 검증 기록 +- 2026-04-13 + - 무엇: `shouldSuppressLiveRoomInfoError` 허용 목록에 실제 테스트에서 사용한 한국어 room-not-found 문구(`라이브 정보가 없습니다.`)를 추가했다. + - 왜: 정책 함수는 다국어/문구 변형을 지원하도록 의도됐지만 한국어 변형 1종이 누락돼 `LiveRoomRoomInfoErrorPolicyTest`가 실패했다. + - 어떻게: + - 수정 파일: `app/src/main/java/kr/co/vividnext/sodalive/live/room/LiveRoomRoomInfoErrorPolicy.kt` + - 실행 명령: `./gradlew :app:testDebugUnitTest :app:assembleDebug` + - 결과: `BUILD SUCCESSFUL` +- 2026-04-13 + - 무엇: `LiveRoomActivity.onUserOffline`에서 방장 부재가 한 번 확인되면 후속 offline 콜백에서는 방 정보 재조회를 하지 않도록 `hasKnownHostAbsence` 가드와 `resolveLiveRoomOfflineAction` 정책 함수를 추가했다. + - 왜: 여러 사용자가 동시에 나가는 상황에서 방장 offline 이후에도 비방장 offline 콜백이 이어지면 `viewModel.getRoomInfo(roomId)`가 중복 호출될 수 있어, 방장이 없는 상태에서는 재조회를 막아야 했다. + - 어떻게: + - 수정 파일: `app/src/main/java/kr/co/vividnext/sodalive/live/room/LiveRoomActivity.kt` + - 추가 파일: `app/src/main/java/kr/co/vividnext/sodalive/live/room/LiveRoomOfflineActionPolicy.kt` + - 추가 파일: `app/src/test/java/kr/co/vividnext/sodalive/live/room/LiveRoomOfflineActionPolicyTest.kt` + - 실행 명령: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.live.room.LiveRoomOfflineActionPolicyTest"` + - 결과: 최초 실행은 `Unresolved reference 'resolveLiveRoomOfflineAction'`로 실패했고, 정책 함수 추가 후 `BUILD SUCCESSFUL`로 통과했다. + - 실행 명령: `./gradlew :app:testDebugUnitTest :app:assembleDebug` + - 결과: `BUILD SUCCESSFUL` + - 진단 도구: Kotlin(`.kt`)용 LSP 서버 미구성으로 `lsp_diagnostics` 실행 불가 확인 +- 2026-04-13 + - 무엇: leave/offline 신호로 발생한 `getRoomInfo`에만 `suppressRoomNotFoundError` 플래그를 추가하고, 종료 race에서 내려오는 `라이브 정보가 없습니다.`/room-not-found 메시지는 토스트로 노출하지 않도록 `shouldSuppressLiveRoomInfoError` 정책 함수를 연결했다. + - 왜: 일반 유저 leave/offline 신호가 방장 종료 신호보다 먼저 도착하면 이미 종료된 방에 대한 재조회가 먼저 실행될 수 있고, 이때의 room-not-found는 실제 장애가 아니라 종료 경합에 따른 기대된 실패이기 때문이다. + - 어떻게: + - 수정 파일: `app/src/main/java/kr/co/vividnext/sodalive/live/room/LiveRoomViewModel.kt` + - 수정 파일: `app/src/main/java/kr/co/vividnext/sodalive/live/room/LiveRoomActivity.kt` + - 추가 파일: `app/src/main/java/kr/co/vividnext/sodalive/live/room/LiveRoomRoomInfoErrorPolicy.kt` + - 추가 파일: `app/src/test/java/kr/co/vividnext/sodalive/live/room/LiveRoomRoomInfoErrorPolicyTest.kt` + - 실행 명령: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.live.room.LiveRoomRoomInfoErrorPolicyTest"` + - 결과: 최초 실행은 `Unresolved reference 'shouldSuppressLiveRoomInfoError'`로 실패했고, 정책 함수 추가 후 `BUILD SUCCESSFUL`로 통과했다. + - 실행 명령: `./gradlew :app:testDebugUnitTest :app:assembleDebug` + - 결과: `BUILD SUCCESSFUL` + - 진단 도구: Kotlin(`.kt`)용 LSP 서버 미구성으로 `lsp_diagnostics` 실행 불가 확인