From a4ba3088b0a4b79c528eb5960bfa499fb18043f2 Mon Sep 17 00:00:00 2001 From: klaus Date: Fri, 20 Mar 2026 10:51:16 +0900 Subject: [PATCH] =?UTF-8?q?feat(live-room):=20=EB=9D=BC=EC=9D=B4=EB=B8=8C?= =?UTF-8?q?=EB=A3=B8=20=EC=B1=84=ED=8C=85=20=EC=82=AD=EC=A0=9C=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EA=B5=AC=ED=98=84=EC=9D=84=20=EB=B0=98=EC=98=81?= =?UTF-8?q?=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sodalive/live/room/LiveRoomActivity.kt | 237 +++++++++++++++++- .../sodalive/live/room/chat/LiveRoomChat.kt | 54 +++- .../live/room/chat/LiveRoomChatAdapter.kt | 22 +- .../live/room/chat/LiveRoomChatRawMessage.kt | 13 +- app/src/main/res/values-en/strings.xml | 2 + app/src/main/res/values-ja/strings.xml | 2 + app/src/main/res/values/strings.xml | 2 + docs/20260319_라이브룸채팅삭제기능구현계획.md | 176 +++++++++++++ 8 files changed, 482 insertions(+), 26 deletions(-) create mode 100644 docs/20260319_라이브룸채팅삭제기능구현계획.md 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 d59ef5f5..03c88577 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 @@ -144,9 +144,14 @@ class LiveRoomActivity : BaseActivity(ActivityLiveRoomB private lateinit var imm: InputMethodManager private val handler = Handler(Looper.getMainLooper()) - private val chatAdapter = LiveRoomChatAdapter { userId -> - showLiveRoomUserProfileDialog(userId = userId) - } + private val chatAdapter = LiveRoomChatAdapter( + onClickProfile = { userId -> + showLiveRoomUserProfileDialog(userId = userId) + }, + onLongClickNormalChat = { chat -> + onLongClickChat(chat) + } + ) private lateinit var layoutManager: LinearLayoutManager private var rvChatBaseBottomMargin: Int? = null @@ -1633,8 +1638,128 @@ class LiveRoomActivity : BaseActivity(ActivityLiveRoomB ) } + private fun onLongClickChat(chat: LiveRoomNormalChat) { + if (!isHost) { + return + } + + LiveDialog( + activity = this, + layoutInflater = layoutInflater, + title = getString(R.string.screen_live_room_chat_delete_title), + desc = getString( + R.string.screen_live_room_chat_delete_message_format, + chat.nickname, + chat.chat + ), + confirmButtonTitle = getString(R.string.confirm_delete_title), + confirmButtonClick = { + deleteChat(chat) + }, + cancelButtonTitle = getString(R.string.cancel), + cancelButtonClick = {} + ).show(screenWidth) + } + + private fun createChatId(): String { + return "${SharedPreferenceManager.userId}_${System.currentTimeMillis()}_${Random.nextInt(1000, 9999)}" + } + + private fun deleteChat(chat: LiveRoomNormalChat) { + if (chat.chatId.isNotBlank()) { + removeNormalChatById(chat.chatId) + } else { + removeNormalChatByUserAndMessage( + userId = chat.userId, + message = chat.chat + ) + } + + agora.sendRawMessageToGroup( + rawMessage = Gson().toJson( + LiveRoomChatRawMessage( + type = LiveRoomChatRawMessageType.DELETE_CHAT, + message = chat.chat, + can = 0, + donationMessage = "", + chatId = chat.chatId.takeIf { it.isNotBlank() }, + targetUserId = chat.userId + ) + ).toByteArray() + ) + } + + private fun deleteChatsByUser(userId: Long) { + if (userId <= 0L) { + return + } + + removeChatsByUserId(userId) + + agora.sendRawMessageToGroup( + rawMessage = Gson().toJson( + LiveRoomChatRawMessage( + type = LiveRoomChatRawMessageType.DELETE_CHAT_BY_USER, + message = "", + can = 0, + donationMessage = "", + targetUserId = userId + ) + ).toByteArray() + ) + } + + private fun removeNormalChatById(chatId: String) { + if (chatId.isBlank()) { + return + } + + val removed = chatAdapter.items.removeAll { chat -> + chat is LiveRoomNormalChat && chat.chatId == chatId + } + + if (removed) { + invalidateChat() + } + } + + private fun removeNormalChatByUserAndMessage(userId: Long, message: String) { + if (userId <= 0L || message.isBlank()) { + return + } + + val targetIndex = chatAdapter.items.indexOfFirst { chat -> + chat is LiveRoomNormalChat && chat.userId == userId && chat.chat == message + } + + if (targetIndex >= 0) { + chatAdapter.items.removeAt(targetIndex) + invalidateChat() + } + } + + private fun removeChatsByUserId(userId: Long) { + if (userId <= 0L) { + return + } + + val removed = chatAdapter.items.removeAll { chat -> + when (chat) { + is LiveRoomNormalChat -> chat.userId == userId + is LiveRoomDonationChat -> chat.memberId == userId + is LiveRoomRouletteDonationChat -> chat.memberId == userId + else -> false + } + } + + if (removed) { + invalidateChat() + } + } + private fun kickOut(userId: Long) { viewModel.kickOut(roomId, userId) + deleteChatsByUser(userId) agora.sendRawMessageToPeer( receiverUid = userId.toString(), requestType = LiveRoomRequestType.KICK_OUT @@ -1725,24 +1850,37 @@ class LiveRoomActivity : BaseActivity(ActivityLiveRoomB ).show() } else if (binding.etChat.text.isNotBlank() && nickname.isNotBlank() && profileUrl.isNotBlank()) { val message = binding.etChat.text.toString() + val chatId = createChatId() chatAdapter.items.add( LiveRoomNormalChat( userId = SharedPreferenceManager.userId, profileUrl = profileUrl, nickname = nickname, rank = rank, - chat = message + chat = message, + chatId = chatId ) ) invalidateChat() - agora.inputChat(message) { - Toast.makeText( - applicationContext, - getString(R.string.screen_live_room_connection_issue), - Toast.LENGTH_SHORT - ).show() - } + agora.sendRawMessageToGroup( + rawMessage = Gson().toJson( + LiveRoomChatRawMessage( + type = LiveRoomChatRawMessageType.NORMAL_CHAT, + message = message, + can = 0, + donationMessage = "", + chatId = chatId + ) + ).toByteArray(), + onFailure = { + Toast.makeText( + applicationContext, + getString(R.string.screen_live_room_connection_issue), + Toast.LENGTH_SHORT + ).show() + } + ) binding.etChat.setText("") } } @@ -2093,6 +2231,81 @@ class LiveRoomActivity : BaseActivity(ActivityLiveRoomB } } + LiveRoomChatRawMessageType.NORMAL_CHAT -> { + if (message.message.isBlank()) { + return + } + + if ( + memberId.toLong() != SharedPreferenceManager.userId && + !viewModel.isNotBlockedMember(memberId.toLong()) + ) { + return + } + + val chatId = message.chatId ?: "" + if (chatId.isBlank()) { + return + } + + handler.post { + val alreadyExists = chatAdapter.items.any { chat -> + chat is LiveRoomNormalChat && chat.chatId == chatId + } + if (!alreadyExists) { + chatAdapter.items.add( + LiveRoomNormalChat( + userId = memberId.toLong(), + profileUrl = profileUrl, + nickname = nickname, + rank = rank, + chat = message.message, + chatId = chatId + ) + ) + invalidateChat() + } + } + } + + LiveRoomChatRawMessageType.DELETE_CHAT -> { + if (!viewModel.isEqualToHostId(memberId.toInt())) { + return + } + + val chatId = message.chatId + if (!chatId.isNullOrBlank()) { + handler.post { + removeNormalChatById(chatId) + } + return + } + + val targetUserId = message.targetUserId ?: return + if (message.message.isBlank()) { + return + } + + handler.post { + removeNormalChatByUserAndMessage( + userId = targetUserId, + message = message.message + ) + } + } + + LiveRoomChatRawMessageType.DELETE_CHAT_BY_USER -> { + if (!viewModel.isEqualToHostId(memberId.toInt())) { + return + } + + val targetUserId = message.targetUserId ?: return + + handler.post { + removeChatsByUserId(targetUserId) + } + } + LiveRoomChatRawMessageType.DONATION -> { handler.post { chatAdapter.items.add( @@ -2149,6 +2362,7 @@ class LiveRoomActivity : BaseActivity(ActivityLiveRoomB handler.post { chatAdapter.items.add( LiveRoomRouletteDonationChat( + memberId = memberId.toLong(), profileUrl = profileUrl, nickname = nickname, rouletteResult = message.message @@ -3789,6 +4003,7 @@ class LiveRoomActivity : BaseActivity(ActivityLiveRoomB handler.post { chatAdapter.items.add( LiveRoomRouletteDonationChat( + memberId = SharedPreferenceManager.userId, profileUrl = SharedPreferenceManager.profileImage, nickname = SharedPreferenceManager.nickname, rouletteResult = randomItem diff --git a/app/src/main/java/kr/co/vividnext/sodalive/live/room/chat/LiveRoomChat.kt b/app/src/main/java/kr/co/vividnext/sodalive/live/room/chat/LiveRoomChat.kt index 992e55b1..3e9f82f3 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/live/room/chat/LiveRoomChat.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/live/room/chat/LiveRoomChat.kt @@ -39,7 +39,8 @@ abstract class LiveRoomChat { abstract fun bind( context: Context, binding: ViewBinding, - onClickProfile: ((Long) -> Unit)? = null + onClickProfile: ((Long) -> Unit)? = null, + onLongClickChat: ((LiveRoomNormalChat) -> Unit)? = null ) } @@ -48,7 +49,12 @@ data class LiveRoomJoinChat( val nickname: String ) : LiveRoomChat() { override var type = LiveRoomChatType.JOIN - override fun bind(context: Context, binding: ViewBinding, onClickProfile: ((Long) -> Unit)?) { + override fun bind( + context: Context, + binding: ViewBinding, + onClickProfile: ((Long) -> Unit)?, + onLongClickChat: ((LiveRoomNormalChat) -> Unit)? + ) { (binding as ItemLiveRoomJoinChatBinding).tvJoin.setTextColor( ContextCompat.getColor(context, R.color.color_eeeeee) ) @@ -88,7 +94,12 @@ data class LiveRoomSystemNoticeChat( val message: String ) : LiveRoomChat() { override var type = LiveRoomChatType.JOIN - override fun bind(context: Context, binding: ViewBinding, onClickProfile: ((Long) -> Unit)?) { + override fun bind( + context: Context, + binding: ViewBinding, + onClickProfile: ((Long) -> Unit)?, + onLongClickChat: ((LiveRoomNormalChat) -> Unit)? + ) { val itemBinding = binding as ItemLiveRoomJoinChatBinding itemBinding.tvJoin.setTextColor( ContextCompat.getColor(context, R.color.color_eeeeee) @@ -104,9 +115,15 @@ data class LiveRoomNormalChat( @SerializedName("profileUrl") val profileUrl: String, @SerializedName("nickname") val nickname: String, @SerializedName("rank") val rank: Int, - @SerializedName("chat") val chat: String + @SerializedName("chat") val chat: String, + @SerializedName("chatId") val chatId: String = "" ) : LiveRoomChat() { - override fun bind(context: Context, binding: ViewBinding, onClickProfile: ((Long) -> Unit)?) { + override fun bind( + context: Context, + binding: ViewBinding, + onClickProfile: ((Long) -> Unit)?, + onLongClickChat: ((LiveRoomNormalChat) -> Unit)? + ) { val itemBinding = binding as ItemLiveRoomChatBinding itemBinding.ivProfile.load(profileUrl) { crossfade(true) @@ -222,6 +239,16 @@ data class LiveRoomNormalChat( } else { itemBinding.llMessageBg.setBackgroundResource(R.drawable.bg_round_corner_3_3_99000000) } + + itemBinding.root.setOnLongClickListener { + if (onLongClickChat != null) { + onLongClickChat(this@LiveRoomNormalChat) + true + } else { + false + } + } + itemBinding.ivCan.visibility = View.GONE itemBinding.tvDonationMessage.visibility = View.GONE itemBinding.root.setBackgroundResource(0) @@ -239,7 +266,12 @@ data class LiveRoomDonationChat( @SerializedName("donationMessage") val donationMessage: String, val isSecret: Boolean = false ) : LiveRoomChat() { - override fun bind(context: Context, binding: ViewBinding, onClickProfile: ((Long) -> Unit)?) { + override fun bind( + context: Context, + binding: ViewBinding, + onClickProfile: ((Long) -> Unit)?, + onLongClickChat: ((LiveRoomNormalChat) -> Unit)? + ) { val itemBinding = binding as ItemLiveRoomChatBinding val defaultProfileSize = 33.3f.dpToPx().toInt() val defaultCrownSize = 16.7f.dpToPx().toInt() @@ -324,6 +356,7 @@ data class LiveRoomDonationChat( itemBinding.llMessageBg.setPadding(0) itemBinding.llMessageBg.background = null + itemBinding.root.setOnLongClickListener(null) if (isSecret) { itemBinding.root.setBackgroundResource(R.drawable.bg_round_corner_6_7_cc59548f) @@ -366,11 +399,17 @@ data class LiveRoomDonationChat( @Keep data class LiveRoomRouletteDonationChat( + @SerializedName("memberId") val memberId: Long, @SerializedName("profileUrl") val profileUrl: String, @SerializedName("nickname") val nickname: String, @SerializedName("rouletteResult") val rouletteResult: String ) : LiveRoomChat() { - override fun bind(context: Context, binding: ViewBinding, onClickProfile: ((Long) -> Unit)?) { + override fun bind( + context: Context, + binding: ViewBinding, + onClickProfile: ((Long) -> Unit)?, + onLongClickChat: ((LiveRoomNormalChat) -> Unit)? + ) { val itemBinding = binding as ItemLiveRoomChatBinding val defaultProfileSize = 33.3f.dpToPx().toInt() val defaultCrownSize = 16.7f.dpToPx().toInt() @@ -433,6 +472,7 @@ data class LiveRoomRouletteDonationChat( itemBinding.llMessageBg.setPadding(0) itemBinding.llMessageBg.background = null + itemBinding.root.setOnLongClickListener(null) itemBinding.root.setBackgroundResource(R.drawable.bg_round_corner_6_7_ccc25264) itemBinding.root.setPadding(33) diff --git a/app/src/main/java/kr/co/vividnext/sodalive/live/room/chat/LiveRoomChatAdapter.kt b/app/src/main/java/kr/co/vividnext/sodalive/live/room/chat/LiveRoomChatAdapter.kt index 020952a3..138f9953 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/live/room/chat/LiveRoomChatAdapter.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/live/room/chat/LiveRoomChatAdapter.kt @@ -10,7 +10,8 @@ import kr.co.vividnext.sodalive.databinding.ItemLiveRoomDonationStatusChatBindin import kr.co.vividnext.sodalive.databinding.ItemLiveRoomJoinChatBinding class LiveRoomChatAdapter( - private val onClickProfile: (Long) -> Unit + private val onClickProfile: (Long) -> Unit, + private val onLongClickNormalChat: ((LiveRoomNormalChat) -> Unit)? = null ) : RecyclerView.Adapter() { val items = mutableListOf() @@ -53,7 +54,7 @@ class LiveRoomChatAdapter( } override fun onBindViewHolder(holder: LiveRoomChatViewHolder, position: Int) { - holder.bind(items[position], onClickProfile) + holder.bind(items[position], onClickProfile, onLongClickNormalChat) } override fun getItemCount() = items.count() @@ -65,7 +66,11 @@ class LiveRoomChatAdapter( abstract class LiveRoomChatViewHolder(binding: ViewBinding) : RecyclerView.ViewHolder(binding.root) { - abstract fun bind(chat: LiveRoomChat, onClickProfile: ((Long) -> Unit)? = null) + abstract fun bind( + chat: LiveRoomChat, + onClickProfile: ((Long) -> Unit)? = null, + onLongClickChat: ((LiveRoomNormalChat) -> Unit)? = null + ) } class LiveRoomNormalChatViewHolder( @@ -74,8 +79,9 @@ class LiveRoomNormalChatViewHolder( ) : LiveRoomChatViewHolder(binding) { override fun bind( chat: LiveRoomChat, - onClickProfile: ((Long) -> Unit)? - ) = chat.bind(context, binding, onClickProfile) + onClickProfile: ((Long) -> Unit)?, + onLongClickChat: ((LiveRoomNormalChat) -> Unit)? + ) = chat.bind(context, binding, onClickProfile, onLongClickChat) } class LiveRoomDonationStatusChatViewHolder( @@ -84,7 +90,8 @@ class LiveRoomDonationStatusChatViewHolder( ) : LiveRoomChatViewHolder(binding) { override fun bind( chat: LiveRoomChat, - onClickProfile: ((Long) -> Unit)? + onClickProfile: ((Long) -> Unit)?, + onLongClickChat: ((LiveRoomNormalChat) -> Unit)? ) = chat.bind(context, binding) } @@ -94,6 +101,7 @@ class LiveRoomJoinChatViewHolder( ) : LiveRoomChatViewHolder(binding) { override fun bind( chat: LiveRoomChat, - onClickProfile: ((Long) -> Unit)? + onClickProfile: ((Long) -> Unit)?, + onLongClickChat: ((LiveRoomNormalChat) -> Unit)? ) = chat.bind(context, binding) } diff --git a/app/src/main/java/kr/co/vividnext/sodalive/live/room/chat/LiveRoomChatRawMessage.kt b/app/src/main/java/kr/co/vividnext/sodalive/live/room/chat/LiveRoomChatRawMessage.kt index f8aa4a24..2cb3c88f 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/live/room/chat/LiveRoomChatRawMessage.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/live/room/chat/LiveRoomChatRawMessage.kt @@ -13,7 +13,9 @@ data class LiveRoomChatRawMessage( @SerializedName("signatureImageUrl") val signatureImageUrl: String? = null, @SerializedName("donationMessage") val donationMessage: String?, @SerializedName("isActiveRoulette") val isActiveRoulette: Boolean? = null, - @SerializedName("isChatFrozen") val isChatFrozen: Boolean? = null + @SerializedName("isChatFrozen") val isChatFrozen: Boolean? = null, + @SerializedName("chatId") val chatId: String? = null, + @SerializedName("targetUserId") val targetUserId: Long? = null ) enum class LiveRoomChatRawMessageType { @@ -35,6 +37,15 @@ enum class LiveRoomChatRawMessageType { @SerializedName("TOGGLE_CHAT_FREEZE") TOGGLE_CHAT_FREEZE, + @SerializedName("NORMAL_CHAT") + NORMAL_CHAT, + + @SerializedName("DELETE_CHAT") + DELETE_CHAT, + + @SerializedName("DELETE_CHAT_BY_USER") + DELETE_CHAT_BY_USER, + @SerializedName("ROULETTE_DONATION") ROULETTE_DONATION, diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index 19098b1a..9e2a69c7 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -467,6 +467,8 @@ Chat is frozen. You cannot type right now. Chat has been frozen. Chat freeze has been disabled. + Delete chat + %1$s: %2$s Sign OFF Caption OFF Caption ON diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 23d1a7d3..24776815 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -466,6 +466,8 @@ チャットが凍結中のため入力できません。 チャットを凍結しました。 チャット凍結を解除しました。 + チャット削除 + %1$s: %2$s シグ OFF 字幕 OFF 字幕 ON diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index cdebe6d9..c69dd162 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -466,6 +466,8 @@ 채팅창이 얼려져 있어 입력할 수 없습니다. 채팅창을 얼렸습니다. 채팅창 얼리기를 해제했습니다 + 채팅 삭제 + %1$s: %2$s 시그 OFF 자막 OFF 자막 ON diff --git a/docs/20260319_라이브룸채팅삭제기능구현계획.md b/docs/20260319_라이브룸채팅삭제기능구현계획.md new file mode 100644 index 00000000..fe49f965 --- /dev/null +++ b/docs/20260319_라이브룸채팅삭제기능구현계획.md @@ -0,0 +1,176 @@ +# 20260319_라이브룸채팅삭제기능구현계획.md + +## 개요 +- 라이브 룸에서 방장(크리에이터) 전용 채팅 삭제 기능을 추가하기 위한 구현 계획 문서다. +- 기능 범위는 단건 삭제(길게 누름 + 확인 다이얼로그)와 강퇴 연계 일괄 삭제(다이얼로그 없이 즉시 삭제)다. +- 본 문서는 계획을 먼저 확정하고, 구현/검증 결과는 하단 `검증 기록`에 누적한다. + +## 요구사항 해석(확정) +- 채팅 삭제 권한은 방장(크리에이터)만 가진다. +- 삭제 대상 채팅을 길게 누르면 삭제 확인 다이얼로그를 띄운다. +- 다이얼로그 본문은 `[닉네임]: [채팅 내용]` 형식으로 노출한다. +- 다이얼로그 버튼은 `취소/삭제` 두 가지다. +- 삭제 확정 시 모든 사용자 화면에서 동일 채팅이 제거되어야 한다. +- 강퇴 시에는 다이얼로그 없이 해당 유저의 채팅을 즉시 일괄 삭제하고, 이 결과가 모든 사용자에게 동기화되어야 한다. + +## 현재 구조 조사 요약 +- 일반 채팅은 `LiveRoomActivity.inputChat()`에서 `agora.inputChat(message)`로 RTM STRING 발송하고, 수신 측은 `onMessageEvent` STRING 분기에서 `LiveRoomNormalChat`을 리스트에 append 한다. +- 실시간 제어 이벤트(방정보 수정, 채팅 얼림, 룰렛 등)는 `LiveRoomChatRawMessageType` + `agora.sendRawMessageToGroup()` + `onMessageEvent` BINARY 분기 패턴을 이미 사용 중이다. +- 강퇴는 `LiveRoomActivity.kickOut()` -> `LiveRoomViewModel.kickOut()`(API) + `LiveRoomRequestType.KICK_OUT` peer 메시지 전송으로 처리되며, 강퇴 대상 단말은 수신 후 `finish()`로 종료한다. +- 현재 라이브룸 채팅 모델/어댑터에는 단건 삭제를 위한 명시적 식별자/long-press 콜백/삭제 브로드캐스트 타입이 없다. + +## 설계 결정 +- 삭제 동기화는 기존 제어 이벤트와 동일하게 RTM BINARY raw message로 처리한다. +- 단건 삭제 정합성을 위해 라이브룸 일반 채팅에 식별자(`chatId`)를 추가한다. +- 강퇴 연계 일괄 삭제는 `targetUserId` 기반 raw message 이벤트로 처리한다. +- 롱프레스 진입은 `LiveRoomChatAdapter`에서 `LiveRoomNormalChat` 항목에만 연결하고, 실제 권한 검증은 `LiveRoomActivity`에서 `isHost`로 최종 보장한다. +- 기존 STRING 채팅 수신 분기는 호환성 fallback으로 유지하고, 삭제 정합성이 필요한 경로는 raw payload 기반으로 처리한다. + +## 완료 기준 (Acceptance Criteria) +- [x] AC1: 방장(크리에이터)만 채팅 길게 누름 시 삭제 확인 다이얼로그를 볼 수 있다. +- [x] AC2: 삭제 다이얼로그에 `[닉네임]: [채팅 내용]` 형식과 `취소/삭제` 버튼이 노출된다. +- [x] AC3: 단건 삭제 확정 시 모든 사용자 화면에서 동일 채팅이 제거된다. +- [x] AC4: 유저 강퇴 시 다이얼로그 없이 해당 유저의 채팅이 일괄 삭제된다. +- [x] AC5: 강퇴 기반 일괄 삭제도 모든 사용자 화면에 동일하게 반영된다. +- [x] AC6: 기존 채팅/후원/강퇴 흐름(삭제 기능 외)은 회귀 없이 유지된다. + +## 구현 체크리스트 +### 1) 채팅 모델/식별자 확장 +- [x] `LiveRoomNormalChat`에 단건 삭제용 `chatId` 필드를 추가한다. +- [x] 강퇴 기반 일괄 삭제 범위를 위해 작성자 식별이 가능한 채팅 타입(`LiveRoomNormalChat`, `LiveRoomDonationChat`, 필요 시 `LiveRoomRouletteDonationChat`)의 사용자 식별 정보를 정리한다. + +### 2) Raw message 스키마 확장 +- [x] `LiveRoomChatRawMessageType`에 삭제 관련 타입(`NORMAL_CHAT`, `DELETE_CHAT`, `DELETE_CHAT_BY_USER`)을 추가한다. +- [x] `LiveRoomChatRawMessage`에 삭제/일반채팅 동기화에 필요한 필드(`chatId`, `targetUserId`)를 nullable로 추가한다. + +### 3) `LiveRoomActivity` 송신/수신 경로 반영 +- [x] `inputChat()`에서 일반 채팅 raw payload 송신 + 로컬 리스트 반영 로직을 정리한다. +- [x] `rtmEventListener.onMessageEvent` BINARY 분기에 `NORMAL_CHAT`, `DELETE_CHAT`, `DELETE_CHAT_BY_USER` 처리 로직을 추가한다. +- [x] STRING 수신 분기는 fallback 경로로 유지하고, 삭제 식별자가 없는 legacy 메시지 처리 원칙을 명시한다. + +### 4) 방장 전용 long-press 삭제 UX +- [x] `LiveRoomChatAdapter`에 `LiveRoomNormalChat` long-press 콜백을 추가한다. +- [x] `LiveRoomActivity`에서 방장 권한(`isHost`) 검증 후 삭제 확인 다이얼로그를 노출한다. +- [x] 다이얼로그 본문은 문자열 포맷으로 `[닉네임]: [채팅 내용]`을 구성하고 `취소/삭제` 액션을 연결한다. + +### 5) 강퇴 연계 일괄 삭제 +- [x] `kickOut(userId)` 경로에서 강퇴 대상 사용자의 채팅 일괄 삭제 raw 이벤트를 다이얼로그 없이 즉시 브로드캐스트한다. +- [x] 수신 측에서 `targetUserId`에 해당하는 채팅을 일괄 제거하고 리스트를 갱신한다. + +### 6) 문자열/국제화 +- [x] `values/strings.xml`에 라이브룸 채팅 삭제 다이얼로그 제목/본문 포맷 문자열을 추가한다. +- [x] `values-en/strings.xml`, `values-ja/strings.xml`에도 동일 키를 추가한다. + +### 7) 검증 +- [x] `lsp_diagnostics`로 수정 파일의 신규 오류 유무를 확인한다. +- [x] `./gradlew :app:testDebugUnitTest`를 실행한다. +- [x] `./gradlew :app:assembleDebug`를 실행한다. +- [ ] 수동 QA: 방장/일반유저 2계정으로 단건 삭제 및 강퇴 일괄 삭제의 전 사용자 반영을 확인한다. + +## 영향 파일(예상) +### 필수 +- `app/src/main/java/kr/co/vividnext/sodalive/live/room/LiveRoomActivity.kt` + - 방장 전용 long-press 삭제 진입, 삭제 확인 다이얼로그, 단건/일괄 삭제 raw 이벤트 송신, 수신 분기 삭제 처리, 강퇴 연계 삭제 처리 추가. +- `app/src/main/java/kr/co/vividnext/sodalive/live/room/chat/LiveRoomChatRawMessage.kt` + - 삭제/일반채팅 동기화용 raw message type 및 payload 필드 추가. +- `app/src/main/java/kr/co/vividnext/sodalive/live/room/chat/LiveRoomChat.kt` + - 단건 삭제용 `chatId` 및 작성자 기반 일괄 삭제 대응 필드(필요 타입) 추가. +- `app/src/main/java/kr/co/vividnext/sodalive/live/room/chat/LiveRoomChatAdapter.kt` + - 일반 채팅 아이템 long-press 콜백 전달 경로 추가. +- `app/src/main/res/values/strings.xml` + - 라이브룸 채팅 삭제 다이얼로그 문자열(제목/본문 포맷) 추가. +- `app/src/main/res/values-en/strings.xml` + - 동일 문자열 키 영문 번역 추가. +- `app/src/main/res/values-ja/strings.xml` + - 동일 문자열 키 일본어 번역 추가. + +### 참고(변경 가능성 낮음) +- `app/src/main/java/kr/co/vividnext/sodalive/agora/Agora.kt` + - 기존 `sendRawMessageToGroup` API로 처리 가능해 직접 수정 가능성은 낮다. + +## 리스크 및 확인사항 +- 일반 채팅 송신 형식을 raw 중심으로 전환할 경우, 구버전/타플랫폼과의 프로토콜 호환성 리스크가 있다. +- fallback STRING 메시지는 삭제 식별자 정합성이 약할 수 있으므로, 삭제 대상 식별 우선순위를 명확히 정의해야 한다. +- `kickOut` API 호출은 현재 성공/실패 콜백을 사용하지 않으므로, “API 성공 후 삭제 이벤트 전파” 순서 보장이 필요하면 ViewModel 시그니처 확장을 검토해야 한다. +- 동일 사용자의 동일 본문 반복 메시지 삭제 시 단건 선택 정합성(정확히 1건 삭제) 검증이 필요하다. + +## 외부 레퍼런스(요약) +- Agora Signaling 메시지 페이로드 구조화 권장(문자열/바이너리 + 앱 스키마): + - https://docs.agora.io/en/signaling/core-functionality/message-payload-structuring +- Stream Chat Android 삭제 권한/삭제 확인 다이얼로그/삭제 이벤트 처리 패턴: + - https://github.com/GetStream/stream-chat-android/blob/d7ce8ede69b0098a06fca17cacecaa9dc0bafdbd/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/utils/CapabilitiesHelper.kt#L137-L155 + - https://github.com/GetStream/stream-chat-android/blob/d7ce8ede69b0098a06fca17cacecaa9dc0bafdbd/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/messages/list/MessageListView.kt#L2269-L2277 + +## 검증 기록 +- 기록 템플릿(후속 누적): + - YYYY-MM-DD + - 무엇/왜/어떻게: + - 실행 명령/도구: + - `명령 또는 사용 도구` + - 결과: + +- 2026-03-19 + - 무엇/왜/어떻게: 라이브룸 채팅 삭제 구현 전에 기존 채팅 송수신/강퇴/어댑터 구조를 병렬 탐색하고, 요구사항을 충족하는 상세 구현 계획(예상 수정 파일/추가 항목/검증 항목)을 문서화했다. + - 실행 명령/도구: + - `task(subagent_type="explore")` x3 + - `task(subagent_type="librarian")` x2 + - `grep("LiveRoomChatRawMessageType|kickOut\(|onMessageEvent|setOnLongClickListener|confirm_delete_title")` + - `ast_grep_search("agora.sendRawMessageToGroup($$$)")` + - `read(LiveRoomActivity.kt, LiveRoomChat.kt, LiveRoomChatAdapter.kt, LiveRoomChatRawMessage.kt, LiveRoomViewModel.kt, Agora.kt, LiveApi.kt, strings*.xml, dialog_live.xml)` + - `bash("rg -n ...")` 시도 + - `apply_patch` (본 문서 생성/상세화) + - 결과: + - `LiveRoomActivity` 중심의 채팅 입력(STRING)/제어이벤트(BINARY)/강퇴(peer) 경로를 확인했다. + - 단건 삭제와 강퇴 일괄 삭제를 위해 필요한 확장 지점(모델, raw 타입, 어댑터 콜백, Activity 분기, 문자열 리소스)을 파일 단위로 확정했다. + - 현재 실행 환경에서 `rg` 명령은 미설치(`command not found`)로 확인되어 동일 탐색은 `grep`/`ast_grep_search`/`read`로 보완했다. + +- 2026-03-19 + - 무엇/왜/어떻게: 계획 문서 기준으로 라이브룸 채팅 삭제 기능(방장 long-press 단건 삭제, 강퇴 연계 일괄 삭제, 전 사용자 동기화)을 실제 코드에 반영하고 빌드/테스트/수동 실행 검증을 수행했다. + - 실행 명령/도구: + - 코드 반영: `apply_patch` (`LiveRoomActivity.kt`, `LiveRoomChat.kt`, `LiveRoomChatAdapter.kt`, `LiveRoomChatRawMessage.kt`, `strings.xml`, `values-en/strings.xml`, `values-ja/strings.xml`) + - 정적 진단: `lsp_diagnostics(LiveRoomActivity.kt, LiveRoomChat.kt, LiveRoomChatAdapter.kt, LiveRoomChatRawMessage.kt, strings*.xml)` + - 테스트/빌드: `./gradlew :app:testDebugUnitTest :app:assembleDebug` + - 수동 실행: `adb devices`, `./gradlew :app:installDebug`, `adb shell monkey -p kr.co.vividnext.sodalive.debug -c android.intent.category.LAUNCHER 1`, `adb shell run-as kr.co.vividnext.sodalive.debug am start --user 0 -n kr.co.vividnext.sodalive.debug/kr.co.vividnext.sodalive.live.room.LiveRoomActivity --el roomId 1` + - 결과: + - 방장 long-press 삭제 진입/확인 다이얼로그/단건 삭제 브로드캐스트, 강퇴 시 즉시 일괄 삭제 브로드캐스트가 코드 경로에 반영되었다. + - `:app:testDebugUnitTest`, `:app:assembleDebug`, `:app:installDebug`가 성공했다. + - `lsp_diagnostics`는 Kotlin/XML LSP 미설정 환경으로 실행 불가 응답을 반환했다. + - 단말 앱 실행 및 `LiveRoomActivity` 인텐트 실행까지는 확인했으나, 이 환경에서는 방장/일반유저 2계정 동시 접속 시나리오를 재현할 계정/세션 준비가 없어 최종 E2E(두 사용자 화면 동시 확인)는 후속 수동 검증이 필요하다. + +- 2026-03-19 + - 무엇/왜/어떻게: Oracle 리뷰에서 삭제 이벤트 수신부의 권한 검증 누락(host-only 보장 약화)과 STRING fallback 채팅 개별 삭제 취약점이 확인되어, 삭제 수신 분기에 방장 검증을 추가하고 fallback 삭제 로직을 보완했다. + - 실행 명령/도구: + - 리뷰: `task(subagent_type="oracle")` + - 코드 반영: `apply_patch` (`LiveRoomActivity.kt`) + - 정적 진단: `lsp_diagnostics(LiveRoomActivity.kt)` + - 테스트/빌드: `./gradlew :app:testDebugUnitTest :app:assembleDebug` + - 수동 실행: `adb devices`, `./gradlew :app:installDebug`, `adb shell monkey -p kr.co.vividnext.sodalive.debug -c android.intent.category.LAUNCHER 1`, `adb shell run-as kr.co.vividnext.sodalive.debug am start --user 0 -n kr.co.vividnext.sodalive.debug/kr.co.vividnext.sodalive.live.room.LiveRoomActivity --el roomId 1` + - 결과: + - `DELETE_CHAT`, `DELETE_CHAT_BY_USER` 수신 처리에서 발신자가 방장인지 검증하도록 반영했다. + - `chatId`가 비어있는 legacy STRING 채팅도 `targetUserId + message` 기준으로 단건 삭제를 시도하도록 보완했다. + - `:app:testDebugUnitTest`, `:app:assembleDebug`, `:app:installDebug`가 모두 성공했다. + - `lsp_diagnostics`는 Kotlin LSP 미설정 환경으로 실행 불가 응답을 반환했다. + +- 2026-03-19 + - 무엇/왜/어떻게: 사용자 요청에 따라 채팅 삭제 다이얼로그 본문 포맷에서 대괄호를 제거했다. + - 실행 명령/도구: + - 코드 반영: `apply_patch` (`app/src/main/res/values/strings.xml`, `app/src/main/res/values-en/strings.xml`, `app/src/main/res/values-ja/strings.xml`) + - 검증: `grep("screen_live_room_chat_delete_message_format")`, `lsp_diagnostics(strings*.xml)`, `./gradlew :app:testDebugUnitTest :app:assembleDebug`, `adb devices`, `./gradlew :app:installDebug`, `adb shell monkey -p kr.co.vividnext.sodalive.debug -c android.intent.category.LAUNCHER 1`, `adb shell run-as kr.co.vividnext.sodalive.debug am start --user 0 -n kr.co.vividnext.sodalive.debug/kr.co.vividnext.sodalive.live.room.LiveRoomActivity --el roomId 1` + - 결과: + - `screen_live_room_chat_delete_message_format` 값이 `[%1$s]: [%2$s]`에서 `%1$s: %2$s`로 변경되어 대괄호 없이 노출된다. + - 테스트/빌드/설치 및 앱/액티비티 실행이 성공했다. + - `lsp_diagnostics`는 XML LSP 미설정 환경으로 실행 불가 응답을 반환했다. + +- 2026-03-19 + - 무엇/왜/어떻게: 삭제 이벤트 payload에서 `targetChatId`를 제거하고 `chatId` 단일 필드로 통일해, 삭제 송신/수신이 동일 키를 사용하도록 정리했다. + - 실행 명령/도구: + - 코드 반영: `apply_patch` (`app/src/main/java/kr/co/vividnext/sodalive/live/room/chat/LiveRoomChatRawMessage.kt`, `app/src/main/java/kr/co/vividnext/sodalive/live/room/LiveRoomActivity.kt`, `docs/20260319_라이브룸채팅삭제기능구현계획.md`) + - 참조 확인: `grep("targetChatId")`, `grep("targetChatId", include="*.md")` + - 정적 진단: `lsp_diagnostics(LiveRoomActivity.kt, LiveRoomChatRawMessage.kt)` + - 테스트/빌드: `./gradlew :app:testDebugUnitTest :app:assembleDebug` + - 수동 실행: `adb devices`, `./gradlew :app:installDebug`, `adb shell monkey -p kr.co.vividnext.sodalive.debug -c android.intent.category.LAUNCHER 1`, `adb shell run-as kr.co.vividnext.sodalive.debug am start --user 0 -n kr.co.vividnext.sodalive.debug/kr.co.vividnext.sodalive.live.room.LiveRoomActivity --el roomId 1` + - 결과: + - `LiveRoomChatRawMessage`에서 `targetChatId` 필드를 제거했고, `DELETE_CHAT` 송신은 `chatId`에 삭제 대상 채팅 ID를 담아 전송하도록 변경했다. + - `DELETE_CHAT` 수신도 `message.chatId`를 읽어 삭제하도록 변경했으며, 저장소 내 `targetChatId` 문자열 참조는 0건이다. + - `:app:testDebugUnitTest`, `:app:assembleDebug`, `:app:installDebug`가 성공했고 앱/액티비티 실행까지 확인했다. + - `lsp_diagnostics`는 Kotlin LSP 미설정 환경으로 실행 불가 응답을 반환했다.