feat(live-room): 라이브룸 채팅 삭제 기능 구현을 반영한다

This commit is contained in:
2026-03-20 10:51:16 +09:00
parent b17a0dcc0e
commit a4ba3088b0
8 changed files with 482 additions and 26 deletions

View File

@@ -144,9 +144,14 @@ class LiveRoomActivity : BaseActivity<ActivityLiveRoomBinding>(ActivityLiveRoomB
private lateinit var imm: InputMethodManager
private val handler = Handler(Looper.getMainLooper())
private val chatAdapter = LiveRoomChatAdapter { 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<ActivityLiveRoomBinding>(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<ActivityLiveRoomBinding>(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) {
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<ActivityLiveRoomBinding>(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<ActivityLiveRoomBinding>(ActivityLiveRoomB
handler.post {
chatAdapter.items.add(
LiveRoomRouletteDonationChat(
memberId = memberId.toLong(),
profileUrl = profileUrl,
nickname = nickname,
rouletteResult = message.message
@@ -3789,6 +4003,7 @@ class LiveRoomActivity : BaseActivity<ActivityLiveRoomBinding>(ActivityLiveRoomB
handler.post {
chatAdapter.items.add(
LiveRoomRouletteDonationChat(
memberId = SharedPreferenceManager.userId,
profileUrl = SharedPreferenceManager.profileImage,
nickname = SharedPreferenceManager.nickname,
rouletteResult = randomItem

View File

@@ -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)

View File

@@ -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<LiveRoomChatViewHolder>() {
val items = mutableListOf<LiveRoomChat>()
@@ -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)
}

View File

@@ -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,

View File

@@ -467,6 +467,8 @@
<string name="screen_live_room_chat_freeze_warning">Chat is frozen. You cannot type right now.</string>
<string name="screen_live_room_chat_freeze_started">Chat has been frozen.</string>
<string name="screen_live_room_chat_freeze_ended">Chat freeze has been disabled.</string>
<string name="screen_live_room_chat_delete_title">Delete chat</string>
<string name="screen_live_room_chat_delete_message_format">%1$s: %2$s</string>
<string name="screen_live_room_signature_off_label">Sign OFF</string>
<string name="screen_live_room_v2v_signature_off_label">Caption OFF</string>
<string name="screen_live_room_v2v_signature_on_label">Caption ON</string>

View File

@@ -466,6 +466,8 @@
<string name="screen_live_room_chat_freeze_warning">チャットが凍結中のため入力できません。</string>
<string name="screen_live_room_chat_freeze_started">チャットを凍結しました。</string>
<string name="screen_live_room_chat_freeze_ended">チャット凍結を解除しました。</string>
<string name="screen_live_room_chat_delete_title">チャット削除</string>
<string name="screen_live_room_chat_delete_message_format">%1$s: %2$s</string>
<string name="screen_live_room_signature_off_label">シグ OFF</string>
<string name="screen_live_room_v2v_signature_off_label">字幕 OFF</string>
<string name="screen_live_room_v2v_signature_on_label">字幕 ON</string>

View File

@@ -466,6 +466,8 @@
<string name="screen_live_room_chat_freeze_warning">채팅창이 얼려져 있어 입력할 수 없습니다.</string>
<string name="screen_live_room_chat_freeze_started">채팅창을 얼렸습니다.</string>
<string name="screen_live_room_chat_freeze_ended">채팅창 얼리기를 해제했습니다</string>
<string name="screen_live_room_chat_delete_title">채팅 삭제</string>
<string name="screen_live_room_chat_delete_message_format">%1$s: %2$s</string>
<string name="screen_live_room_signature_off_label">시그 OFF</string>
<string name="screen_live_room_v2v_signature_off_label">자막 OFF</string>
<string name="screen_live_room_v2v_signature_on_label">자막 ON</string>

View File

@@ -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 미설정 환경으로 실행 불가 응답을 반환했다.