채팅 - 채팅방 나가기

변현섭·2023년 9월 13일
0
post-thumbnail

이번 포스팅에서는 채팅방을 나갈 수 있는 기능을 추가해보겠습니다.

1. 채팅방 나가기

채팅방 목록에서 채팅방을 Long Click하면, 채팅방을 나갈 것인지 묻는 Dialog를 띄워주기로 하자.

① layout 디렉토리 하위로, exit_room_dilag라는 리소스 파일을 추가한다.

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical">

    <TextView
        android:id="@+id/roomNameArea"
        android:layout_width="match_parent"
        android:layout_height="80dp"
        android:layout_marginHorizontal="10dp"
        android:text="ChatRoom Name"
        android:textSize="30sp"
        android:textStyle="bold"
        android:textColor="#000000"
        android:gravity="center"
        android:background="@android:color/transparent"/>

    <TextView
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginHorizontal="10dp"
        android:text="채팅방을 나가시겠습니까?"
        android:textSize="20sp"
        android:textColor="#000000"
        android:gravity="center"
        android:background="@android:color/transparent"/>

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="100dp">

        <LinearLayout
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:gravity="center"
            android:layout_marginTop="30dp"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent">
        
            <Button
                android:id="@+id/exit"
                android:layout_width="0dp"
                android:layout_height="wrap_content"
                android:layout_marginLeft="30dp"
                android:layout_marginRight="10dp"
                android:layout_weight="1"
                android:background="@drawable/main_border"
                android:text="OK"
                android:textSize="20sp"
                app:layout_constraintEnd_toEndOf="parent"
                app:layout_constraintStart_toStartOf="parent"
                app:layout_constraintTop_toTopOf="parent" />

            <Button
                android:id="@+id/exitCancel"
                android:layout_width="0dp"
                android:layout_height="wrap_content"
                android:layout_marginRight="30dp"
                android:layout_marginLeft="10dp"
                android:layout_weight="1"
                android:background="@drawable/main_border"
                android:text="CANCEL"
                android:textSize="20sp"
                app:layout_constraintEnd_toEndOf="parent"
                app:layout_constraintStart_toStartOf="parent"
                app:layout_constraintTop_toTopOf="parent" />
            
        </LinearLayout>

    </androidx.constraintlayout.widget.ConstraintLayout>

</LinearLayout>

② ChatApi에 아래의 API를 추가한다.

@DELETE("/chat/room/{roomId}")
suspend fun exitChatRoom(
    @Header("Authorization") accessToken : String,
    @Path("roomId") roomId : String
) : BaseResponse<String>

③ ChatListFragment에 채팅방 목록에 대한 Long Click 이벤트 리스너를 등록한다.

listView.setOnItemLongClickListener { parent, view, position, id ->
    showExitDialog(chatRoomList!![position].chatRoomId!!, chatRoomList!![position].roomName!!)
    return@setOnItemLongClickListener(true)
}

④ showExitDialog()를 아래와 같이 정의한다.

private fun showExitDialog(roomId : String, roomName : String) {
    val dialogView = LayoutInflater.from(requireActivity()).inflate(R.layout.exit_room_dialog, null)
    val builder = AlertDialog.Builder(requireActivity())
        .setView(dialogView)
        .setTitle("채팅방 나가기")
    val alertDialog = builder.show()
    val roomNameArea = alertDialog.findViewById<TextView>(R.id.roomNameArea)
    roomNameArea.text = roomName
    val exitBtn = alertDialog.findViewById<Button>(R.id.exit)
    val cancelBtn = alertDialog.findViewById<Button>(R.id.exitCancel)
    exitBtn.setOnClickListener {
            getAccessToken { accessToken ->
                if (accessToken.isNotEmpty()) {
                    CoroutineScope(Dispatchers.IO).launch {
                        val response = exitChatRoom(accessToken, roomId)
                        if (response.isSuccess) {
                            Log.d("ChatListFragment", response.toString())
                            FirebaseRef.chatRoom.child(FirebaseAuthUtils.getUid()).child(roomId!!).removeValue()
                                .addOnCompleteListener { removeChatRoomTask ->
                                    CoroutineScope(Dispatchers.IO).launch {
                                        val response = getUserCount(roomId!!)
                                        Log.d("userCount", response.toString())
                                        if (!response.isSuccess) {
                                            // 채팅방 안에 아무도 없다.(채팅방 ID가 존재하지 않는 채팅방을 가리킨다.)
                                            FirebaseRef.message.child(roomId!!).removeValue()
                                        }
                                    }
                                }
                        }
                        else {
                            Log.d("ChatListFragment", "채팅방 나가기 실패")
                            val message = response.message
                            Log.d("ChatListFragment", message)
                            withContext(Dispatchers.Main) {
                                Toast.makeText(requireActivity(), message, Toast.LENGTH_SHORT).show()
                            }
                        }
                    }
                } else {
                    Log.e("ChatListFragment", "Invalid Token")
                }
            }
            alertDialog.dismiss()
        }
    cancelBtn.setOnClickListener {
        alertDialog.dismiss()
    }
}

⑥ 채팅방이 없어질 때 NPE가 발생하지 않도록 아래와 같이 getChatRoomList 메서드를 수정한다.

private fun getChatRoomList() {
    val postListener = object : ValueEventListener {
        override fun onDataChange(dataSnapshot: DataSnapshot) {
            chatRoomList.clear()
            for (dataModel in dataSnapshot.children) {
                val chatRoom = dataModel.getValue(ChatRoom::class.java)
                if(chatRoom != null) {
                    chatRoomList.add(chatRoom)
                    if (chatRoom.chatRoomId != null) {
                        getUnreadMessageCount(chatRoom.chatRoomId)
                    }
                }
            }
            listViewAdapter.notifyDataSetChanged()
        }
        override fun onCancelled(databseError: DatabaseError) {
            Log.w("MyMessage", "onCancelled", databseError.toException())
        }
    }
    FirebaseRef.chatRoom.child(FirebaseAuthUtils.getUid()).addValueEventListener(postListener)
}

⑦ ChatListFragment에 아래의 메서드를 추가한다.

private suspend fun getUserCount(roomId: String) : BaseResponse<String> {
    return RetrofitInstance.chatApi.getUserCount(roomId)
}

private suspend fun exitChatRoom(accessToken : String, roomId: String): BaseResponse<String> {
    return RetrofitInstance.chatApi.exitChatRoom(accessToken, roomId)
}

⑧ 채팅방을 나갈 때에 이벤트 트리거에 의해 getUnreadMessageCount가 실행되면서, unreadCount와 lastMessage가 추가되는 문제가 발생하지 않도록, getUnreadMessageCount 메서드를 아래와 같이 수정하자.

private fun getUnreadMessageCount(chatRoomId : String) {
    val postListener = object : ValueEventListener {
        override fun onDataChange(dataSnapshot: DataSnapshot) {
            var count = 0
            var lastMessage = ""
            for (datamModel in dataSnapshot.children) {
                val uidList = datamModel.child("readerUids").getValue(object : GenericTypeIndicator<MutableMap<String, Boolean>>() {})
                if (uidList != null) {
                    Log.d("readerUids", uidList.toString())
                    if (!uidList.containsKey(FirebaseAuthUtils.getUid())) {
                        // readerUid에 내 uid가 없으면
                        count++
                    }
                }
                val lastDataModel = datamModel.getValue(MessageModel::class.java)
                if(lastDataModel != null) {
                    lastMessage = lastDataModel.contents
                }
            }
            CoroutineScope(Dispatchers.IO).launch {
                val response = getUserList(chatRoomId!!)
                if (response.isSuccess) {
                    val participantsList = response.result
                    Log.d("participantsList", participantsList.toString())
                    for(participant in participantsList!!) {
                        if(participant.uid == FirebaseAuthUtils.getUid()) { // 내가 이 채팅방에 존재할 때만 실행
                            FirebaseRef.chatRoom.child(FirebaseAuthUtils.getUid()).child(chatRoomId).child("unreadCount").setValue(count)
                            FirebaseRef.chatRoom.child(FirebaseAuthUtils.getUid()).child(chatRoomId).child("lastMessage").setValue(lastMessage)
                        }
                    }
                } else {
                    val message = response.message
                    Log.d("ChatListFragment", message)
                }
            }
        }
        override fun onCancelled(databaseError: DatabaseError) {
            Log.w("MyMessage", "onCancelled", databaseError.toException())
        }
    }
    FirebaseRef.message.child(chatRoomId).addValueEventListener(postListener)
}

이제 코드를 실행시킨 후 채팅방 목록에서 채팅방을 Long Click하면 채팅방을 나갈 것인지 묻는 Dialog가 나타나는데, 이 Dialog의 OK 버튼을 클릭하여 채팅방에서 나갈 수 있다.

이 때, 채팅방이 단순히 화면에서만 사라지는게 아니라, 파이어베이스의 Realtime Database와 RDS에서도 사라져야 하며, 채팅방의 참여자 목록과 참여자 수에도 변경사항이 반영되어야 한다.

2. 메시지를 읽지 않은 유저의 수 수정하기

이제 문제가 하나 생길 것이다. 메시지를 읽지 않은 유저의 숫자를 readerUidMap의 사이즈를 이용해 계산했기 때문에 메시지를 읽은 유저가 나갈 경우, 안 읽은 유저의 수가 제대로 계산되지 않는다.

자세히 설명하자면, 만약 A, B, C 3명의 유저가 참여한 채팅방에서 A가 메시지를 보내고 B가 그 메시지를 읽었다. 그러면 메시지 옆에는 1이라는 노란 숫자가 나타날 것이고, 이 1은 C가 아직 읽지 않았음을 의미한다. 이 상황에서 B가 채팅방을 나가게 되면, 채팅방의 총 인원은 2명(A, C)이 되고, 읽은 사람도 2명(A, B)이 되므로 메시지 옆에 노란 숫자가 사라진다. 하지만, 아직 C가 메시지를 읽지 않은 상태이기 때문에, 이것은 정확한 정보가 아니다.

그래서, 조금 더 복잡한 방식을 사용해야 할 것 같다. 현재 채팅방 참여자의 uidList를 API로 받아오고, readerUidMap의 key 값을 List로 변환한 후, 이 두 List의 교집합의 size를 해당 메시지를 읽은 유저 수로 판단하기로 하자. 만약 채팅방을 나간다면 readerUidMap에 속해 있다 하더라도 uidList에 속하지 않기 때문에 계산에서 제외된다.

1) 백엔드

① ChatRoomController에 아래의 API를 추가한다.

// 채팅에 참여한 유저의 UID 리스트 반환
@GetMapping("/room/{roomId}")
public BaseResponse<List<String>> getUserUidList(@PathVariable String roomId) {
    try {
        return new BaseResponse<>(chatRoomService.getUserUidListById(roomId));
    } catch (BaseException exception) {
        return new BaseResponse<>(exception.getStatus());
    }
}

② ChatRoomService에 아래의 메서드를 추가한다.

public List<String> getUserUidListById(String chatRoomId) throws BaseException {
    utilService.findChatRoomByChatRoomIdWithValidation(chatRoomId);
    List<UserChatRoom> userChatRooms = userChatRoomRepository.findUserChatRoomByRoomId(chatRoomId);
    List<String> uidList = new ArrayList<>();
    for (UserChatRoom userChatRoom : userChatRooms) {
        uidList.add(userChatRoom.getUser().getUid());
    }
    return uidList;
}

2) 프론트엔드

① ChatApi에 아래의 API를 추가한다.

@GET("/chat/uidList/{roomId}")
suspend fun getUserUidList(
    @Path("roomId") roomId : String
) : BaseResponse<List<String>>

② ChatRoomActivity를 아래와 같이 수정한다.

  • API를 이용해 채팅방에 속한 유저의 UID 목록을 uidList에 받는다.
  • getMessageList에서 uidList를 받아 messageModel.readerUids의 key로 구성된 List와 교집합 List를 구한 후, API로 가져온 채팅방의 총 인원 수에서 교집합 List의 size를 빼 메시지의 unreadUserCount를 업데이트한다.
  • 이전 포스팅에서는 전역변수로 count를 가져왔었는데, 첫 메시지의 unreadCount가 불안정한 이슈가 있어서 지역변수로 count를 가져오는 방식으로 변경하였다.
  • 참고로, uidList 또는 count를 getMessageList 메서드 내부가 아닌 다른 곳에서 구한 값을 가져다 쓸 경우, 제대로 된 결과가 나타나지 않을 수도 있으니 주의하자.
class ChatRoomActivity : AppCompatActivity() {

    lateinit var messageAdapter : MessageAdapter
    lateinit var recyclerView : RecyclerView
    val messageList = mutableListOf<MessageModel>()
    var tokenList = listOf<String>() // 채팅 참여자의 토큰 목록
    val readerUidMap = mutableMapOf<String, Boolean>()

    @RequiresApi(Build.VERSION_CODES.O)
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_chat_room)

        readerUidMap[FirebaseAuthUtils.getUid()] = true
        Log.d("readerUidMap", readerUidMap.toString())
        val roomName = findViewById<TextView>(R.id.chatRoomName)
        val nickNameList = findViewById<TextView>(R.id.nickNameList)
        val inviteBtn = findViewById<Button>(R.id.invite)
        val userCount = findViewById<TextView>(R.id.userCount)

        // Intent로부터 데이터를 가져옴
        val chatRoomName = intent.getStringExtra("chatRoomName")
        roomName.text = chatRoomName

        val roomId = intent.getStringExtra("chatRoomId")
        val chatRoomId = roomId

        CoroutineScope(Dispatchers.IO).launch {
            val response = getUserCount(chatRoomId!!)
            Log.d("userCount", response.toString())
            if (response.isSuccess) {
                val count = response.result.toString()
                userCount.text = count
            } else {
                Log.d("UserListFragment", "유저의 정보를 불러오지 못함")
            }
        }

        CoroutineScope(Dispatchers.IO).launch {
            val response = getUserStrList(chatRoomId!!)
            Log.d("UserNickNameList", response.toString())
            if (response.isSuccess) {
                nickNameList.text = response.result.toString()
            } else {
                Log.d("UserNickNameList", "유저의 정보를 불러오지 못함")
            }
        }

        getAccessToken { accessToken ->
            if (accessToken.isNotEmpty()) {
                CoroutineScope(Dispatchers.IO).launch {
                    val response = getTokenList(accessToken, chatRoomId!!)
                    if (response.isSuccess) {
                        tokenList = response.result!!
                        Log.d("TokenList", response.toString())
                    } else {
                        Log.d("TokenList", "유저의 정보를 불러오지 못함")
                    }
                }
            } else {
                Log.e("TokenList", "Invalid Token")
            }
        }

        recyclerView = findViewById(R.id.messageRV)
        messageAdapter = MessageAdapter(this, messageList)
        recyclerView.adapter = messageAdapter
        recyclerView.layoutManager = LinearLayoutManager(this)

        getMessageList(chatRoomId!!)
        Log.d("MessageList", messageList.toString())

        inviteBtn.setOnClickListener {
            val intent = Intent(this, InviteActivity::class.java)
            intent.putExtra("chatRoomId", chatRoomId)
            intent.putExtra("chatRoomName", chatRoomName)
            startActivity(intent)
        }

        val participantBtn = findViewById<ImageView>(R.id.participants)
        participantBtn.setOnClickListener {
            val intent = Intent(this, ParticipantsActivity::class.java)
            intent.putExtra("chatRoomId", chatRoomId)
            startActivity(intent)
        }

        val message = findViewById<TextInputEditText>(R.id.message)
        val sendBtn = findViewById<ImageView>(R.id.send)

        lateinit var myNickName : String
        lateinit var myProfileUrl : String
        lateinit var messageModel: MessageModel
        val myUid = FirebaseAuthUtils.getUid()

        sendBtn.setOnClickListener {
            val contents = message.text.toString()
            if(contents.isEmpty()) {
                Toast.makeText(this, "메시지를 입력해주세요", Toast.LENGTH_SHORT).show()
            }
            else {
                val sendTime = getSendTime()
                CoroutineScope(Dispatchers.IO).launch {
                    val response = getUserInfo(myUid)
                    if (response.isSuccess) {
                        myNickName = response.result?.nickName.toString()
                        myProfileUrl = response.result?.imgUrl.toString()
                        val readerUids = mutableMapOf<String, Boolean>()
                        messageModel = MessageModel(myUid, myNickName, myProfileUrl, contents,
                            sendTime, readerUids, 0)
                        Log.d("readerUid", readerUids.size.toString())
                        FirebaseRef.message.child(chatRoomId!!).push().setValue(messageModel)
                        val keysList: List<String> = ArrayList<String>(messageModel.readerUids.keys)
                        
                        val noticeModel = NoticeModel(myNickName, contents)
                                for(token in tokenList) { // 채팅방의 모든 유저에게 채팅 푸시알림을 전송
                                    val pushNotice = PushNotice(noticeModel, token)
                                    Log.d("Push", pushNotice.toString())
                                    Log.d("Push", tokenList.toString())
                                    createNotificationChannel()
                                    pushNotification(pushNotice)
                                }
                }
                message.text?.clear()
            }
        }
    }

    override fun onBackPressed() {
        readerUidMap.remove(FirebaseAuthUtils.getUid())
        val intent = Intent(this, MainActivity::class.java)
        startActivity(intent)
    }

    private suspend fun getUserCount(roomId: String) : BaseResponse<String> {
        return RetrofitInstance.chatApi.getUserCount(roomId)
    }

    private suspend fun getUserInfo(uid: String): BaseResponse<GetUserRes> {
        return RetrofitInstance.myPageApi.getUserInfo(uid)
    }

    //메시지 보낸 시각 정보 반환
    @RequiresApi(Build.VERSION_CODES.O)
    private fun getSendTime(): String {
        try {
            val localDateTime = LocalDateTime.now()
            val dateTimeFormatter = DateTimeFormatter.ofPattern("M/d  a h:mm")
            return localDateTime.format(dateTimeFormatter)
        } catch (e: Exception) {
            e.printStackTrace()
            throw Exception("시간 정보를 불러오지 못함")
        }
    }

    private fun getMessageList(chatRoomId: String) {
        val postListener = object : ValueEventListener {
            override fun onDataChange(dataSnapshot: DataSnapshot) {
                messageList.clear()
                val newMessages = mutableListOf<MessageModel>() // 새로운 메시지를 저장할 리스트 생성

                for (dataModel in dataSnapshot.children) {
                    val messageModel = dataModel.getValue(MessageModel::class.java)
                    val messageId = dataModel.key
                    if (messageModel != null) {
                        if (messageId != null) {
                            // readerUidMap을 업데이트
                            FirebaseRef.message.child(chatRoomId!!).child(messageId).child("readerUids")
                                .updateChildren(readerUidMap as Map<String, Boolean>)
                                .addOnCompleteListener { readerUidTask ->
                                    if (readerUidTask.isSuccessful) {
                                        CoroutineScope(Dispatchers.IO).launch {
                                            val response = getUserUidList(chatRoomId!!)
                                            Log.d("userUidList", response.toString())
                                            if (response.isSuccess) {
                                                val newUidList = response.result!!
                                                Log.d("newUidList", response.result.toString())
                                                Log.d("newUidList", newUidList.toString())
                                                val keysList: List<String> = ArrayList<String>(messageModel.readerUids.keys)
                                                val intersection = newUidList.intersect(keysList)
                                                CoroutineScope(Dispatchers.IO).launch {
                                                    val response = getUserCount(chatRoomId!!)
                                                    Log.d("userCount", response.toString())
                                                    if (response.isSuccess) {
                                                        val count = response.result.toString()
                                                        val unreadUserCount = count.toInt() - intersection.size
                                                        Log.d("unreadUserCount", "1. " + count)
                                                        Log.d("unreadUserCount", "2. " + intersection.size.toString())
                                                        FirebaseRef.message.child(chatRoomId!!).child(messageId).child("unreadUserCount")
                                                            .setValue(unreadUserCount)
                                                    } else {
                                                        Log.d("UserListFragment", "유저의 정보를 불러오지 못함")
                                                    }
                                                }
                                            } else {
                                                Log.d("newUidList", "유저의 정보를 불러오지 못함")
                                            }
                                        }
                                    }

                                    else {
                                        Log.d("ChatRoomActivity", "reader UID를 업데이트하지 못함")
                                    }
                                }
                        }

                        if (messageModel.senderUid != FirebaseAuthUtils.getUid()) {
                            messageModel.viewType = MessageModel.VIEW_TYPE_YOU
                            if(!readerUidMap.containsKey(FirebaseAuthUtils.getUid())) {

                            }
                        }
                        newMessages.add(messageModel)
                    }
                }

                messageList.addAll(newMessages)
                messageAdapter.notifyDataSetChanged()
                Log.d("MessageList", messageList.toString())

                recyclerView.post {
                    recyclerView.scrollToPosition(recyclerView.adapter?.itemCount?.minus(1) ?: 0)
                }
            }

            override fun onCancelled(databaseError: DatabaseError) {
                Log.w("MyMessage", "onCancelled", databaseError.toException())
            }
        }
        FirebaseRef.message.child(chatRoomId).addValueEventListener(postListener)
    }

    private suspend fun getUserStrList(roomId: String): BaseResponse<String> {
        return RetrofitInstance.chatApi.getUserStrList(roomId)
    }

    private suspend fun getTokenList(accessToken : String, roomId: String): BaseResponse<List<String>> {
        return RetrofitInstance.chatApi.getTokenList(accessToken, roomId)
    }

    private suspend fun getUserUidList(roomId: String): BaseResponse<List<String>> {
        return RetrofitInstance.chatApi.getUserUidList(roomId)
    }

    private fun createNotificationChannel() {
        // Create the NotificationChannel, but only on API 26+ because
        // the NotificationChannel class is new and not in the support library
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            val name = "name"
            val descriptionText = "description"
            val importance = NotificationManager.IMPORTANCE_DEFAULT
            val channel = NotificationChannel("test", name, importance).apply {
                description = descriptionText
            }
            // Register the channel with the system
            val notificationManager: NotificationManager =
                getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
            notificationManager.createNotificationChannel(channel)
        }
    }

    private fun pushNotification(notification: PushNotice) = CoroutineScope(Dispatchers.IO).launch {
        RetrofitInstance.noticeApi.postNotification(notification)
    }

    private fun getAccessToken(callback: (String) -> Unit) {
        val postListener = object : ValueEventListener {
            override fun onDataChange(dataSnapshot: DataSnapshot) {
                val data = dataSnapshot.getValue(com.chrome.chattingapp.authentication.UserInfo::class.java)
                val accessToken = data?.accessToken ?: ""
                callback(accessToken)
            }

            override fun onCancelled(databaseError: DatabaseError) {
                Log.w("ChatRoomActivity", "onCancelled", databaseError.toException())
            }
        }

        FirebaseRef.userInfo.child(FirebaseAuthUtils.getUid()).addListenerForSingleValueEvent(postListener)
    }
}

이제 유저가 채팅방에서 나간 경우까지 고려하여 정확한 unreadCount를 메시지 옆에 표시할 것이다. 아래의 사진은 현섭이 크롬이 보낸 메시지를 읽고 채팅방을 나간 상황이다. 메시지를 2명이 읽었고, 채팅방에 2명밖에 안 남았음에도 불구하고, karina가 읽지 않은 것을 정확히 계산하고 있다.

3. 채팅방에 접속해 있지 않은 유저에게만 Push 알림 전송하기

이전 포스팅에서 push 알림 관련 이슈(채팅방에 접속해 있어도 push 알림이 도착하는 문제)에 대해 잠깐 이야기하면서, 해결방법은 다음 포스팅에서 다루겠다고 하였다. 이 이슈를 이번 포스팅에서 다루겠다고 말한 이유는 채팅방 나가기 기능에 사용한 로직과 유사한 방법을 적용하여 이 문제를 해결할 수 있기 때문이다.

일단, 채팅방에 접속해 있지 않은 유저에게만 Push 알림 전송하는 방법에 대해 간단히 설명하겠다. ChatRoomActivity에 유저가 들어왔을 때 Firebase의 connected에 해당 유저의 uid를 저장하기 위해 onCreate 콜백메서드를 사용한다. 만약 유저가 ChatRoomActivity를 나갈 경우, 그 유저를 connected에서 삭제하기 위해 Back Button에 대한 클릭 이벤트 리스너와 onPaused 콜백 메서드를 이용한다.

채팅방에 참여한 유저의 UID 목록을 uidList로 받고, connected에 존재하는 UID 목록을 connectedUidsList로 받아 uidList와 connectedUidsList의 차집합 List(채팅방에는 속해있지만, 현재 채팅방에 들어와있지 않은 유저의 UID 목록)를 얻는다. 마지막으로 uid에 해당하는 유저의 device token을 API로 받아 deviceTokenList에 저장하고, 이 deviceTokenList를 순회하면서, createNotificationChannel() 메서드와 pushNotification() 메서드를 호출한다.

1) 백엔드

① UserController에 아래의 API를 추가한다.

/**
 * uid에 해당하는 유저의 디바이스 토큰 반환
 */
@GetMapping("/uidToToken")
public BaseResponse<String> getDeviceTokenByUid(@RequestParam String uid) {
    try {
        return new BaseResponse<>(userService.getDeviceTokenByUid(uid));
    } catch (BaseException exception) {
        return new BaseResponse<>(exception.getStatus());
    }
}

② UserService에 아래의 메서드를 추가한다.

public String getDeviceTokenByUid(String uid) throws BaseException {
    User user = utilService.findByUserUidWithValidation(uid);
    return user.getDeviceToken();
}

2) 프론트엔드

① UserApi에 아래의 API를 추가한다.

@GET("/users/uidToToken")
suspend fun getDeviceTokenByUid(
    @Query("uid") uid : String
) : BaseResponse<String>

② FirebaseRef에 아래의 경로를 추가한다.

val connected = database.getReference("connected")

③ ChatRoomActivity의 onCreate 콜백 메서드에서 현재 채팅방에 존재하는 유저의 uid를 파이어베이스에 업로드하는 로직을 정의한다.

  • setValue를 쓰면 갚이 overwrite되기 때문에 push().setValue를 사용했다.
val connectedUids = mutableMapOf<String, Boolean>() // 현재 채팅방에 입장한 유저의 UID 맵
...
	connectedUids[FirebaseAuthUtils.getUid()] = true
	FirebaseRef.connected.child(chatRoomId!!).push().setValue(connectedUids)

④ Back 버튼에 대한 클릭 이벤트 리스너도 아래와 같이 수정한다.

  • 기존 readerUidMap을 connectedUids로 대체할 것이므로 관련 코드는 모두 지워주자.
override fun onBackPressed() {
    val uid = FirebaseAuthUtils.getUid() // 현재 사용자의 UID
    val connectedUidsRef = FirebaseRef.connected.child(chatRoomId)
    connectedUidsRef.addListenerForSingleValueEvent(object : ValueEventListener {
        override fun onDataChange(dataSnapshot: DataSnapshot) {
            for (childSnapshot in dataSnapshot.children) {
                // 각 난수 키에 대한 데이터를 가져옵니다.
                val randomKeyData = childSnapshot.child(uid)
                if (randomKeyData.exists()) {
                    connectedUidsRef.child(childSnapshot.key!!).child(uid).removeValue()
                }
            }
        }
        override fun onCancelled(databaseError: DatabaseError) {
            // 처리 중 오류 발생 시 처리
        }
    })
    val intent = Intent(this, MainActivity::class.java)
    startActivity(intent)
}

⑤ onPause 콜백 메서드도 정의해야 한다.

  • Back 버튼의 클릭 이벤트 리스너를 정의했음에도 onPause를 따로 쓰는 이유는 앱을 종료하거나 채팅방을 나가지 않는(홈버튼을 누르는 등) 상황에서도 push 알림을 전송하기 위함이다.
  • 메시지를 읽지 않은 유저의 수를 계산할 때에도 동일한 로직을 적용한다(기존에는 홈 버튼을 누른 유저는 메시지를 읽은 것으로 간주했지만, 이제는 홈 버튼을 누른 유저도 메시지를 읽지 않은 것으로 간주한다).
override fun onPause() {
    super.onPause()
    val uid = FirebaseAuthUtils.getUid() // 현재 사용자의 UID
    val connectedUidsRef = FirebaseRef.connected.child(chatRoomId)
    connectedUidsRef.addListenerForSingleValueEvent(object : ValueEventListener {
        override fun onDataChange(dataSnapshot: DataSnapshot) {
            for (childSnapshot in dataSnapshot.children) {
                // 각 난수 키에 대한 데이터를 가져옵니다.
                val randomKeyData = childSnapshot.child(uid)
                if (randomKeyData.exists()) {
                    connectedUidsRef.child(childSnapshot.key!!).child(uid).removeValue()
                }
            }
        }
        override fun onCancelled(databaseError: DatabaseError) {
            // 처리 중 오류 발생 시 처리
        }
    })
}

⑥ 그리고 Pause가 끝나 액티비티를 화면에 표시할 때, 다시 해당 유저의 UID를 connectedUids에 넣는다.

override fun onRestart() {
    super.onRestart()
    connectedUids[FirebaseAuthUtils.getUid()] = true
    FirebaseRef.connected.child(chatRoomId!!).setValue(connectedUids)
}

⑦ ChatRoomActivity를 아래와 같이 수정한다.

  • sendBtn 클릭 이벤트 리스너에서 uidList와 connectedUidsList의 차집합 List를 만든다.
  • 이후 List의 uid에 해당하는 유저의 deviceToken을 deviceTokenList에 넣고, 이 deviceTokenList를 순회하면서 Push 알림을 전송한다.
class ChatRoomActivity : AppCompatActivity() {

    lateinit var messageAdapter : MessageAdapter
    lateinit var recyclerView : RecyclerView
    lateinit var chatRoomId : String
    val messageList = mutableListOf<MessageModel>()
    var tokenList = listOf<String>() // 채팅 참여자의 토큰 목록
    val connectedUids = mutableMapOf<String, Boolean>() // 현재 채팅방에 입장한 유저의 UID 맵

    @RequiresApi(Build.VERSION_CODES.O)
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_chat_room)

        val roomName = findViewById<TextView>(R.id.chatRoomName)
        val nickNameList = findViewById<TextView>(R.id.nickNameList)
        val inviteBtn = findViewById<Button>(R.id.invite)
        val userCount = findViewById<TextView>(R.id.userCount)

        // Intent로부터 데이터를 가져옴
        val chatRoomName = intent.getStringExtra("chatRoomName")
        roomName.text = chatRoomName

        val roomId = intent.getStringExtra("chatRoomId")
        chatRoomId = roomId!!

        connectedUids[FirebaseAuthUtils.getUid()] = true
        FirebaseRef.connected.child(chatRoomId!!).push().setValue(connectedUids)

        CoroutineScope(Dispatchers.IO).launch {
            val response = getUserCount(chatRoomId!!)
            Log.d("userCount", response.toString())
            if (response.isSuccess) {
                val count = response.result.toString()
                userCount.text = count
            } else {
                Log.d("UserListFragment", "유저의 정보를 불러오지 못함")
            }
        }

        CoroutineScope(Dispatchers.IO).launch {
            val response = getUserStrList(chatRoomId!!)
            Log.d("UserNickNameList", response.toString())
            if (response.isSuccess) {
                nickNameList.text = response.result.toString()
            } else {
                Log.d("UserNickNameList", "유저의 정보를 불러오지 못함")
            }
        }

        getAccessToken { accessToken ->
            if (accessToken.isNotEmpty()) {
                CoroutineScope(Dispatchers.IO).launch {
                    val response = getTokenList(accessToken, chatRoomId!!)
                    if (response.isSuccess) {
                        tokenList = response.result!!
                        Log.d("TokenList", response.toString())
                    } else {
                        Log.d("TokenList", "유저의 정보를 불러오지 못함")
                    }
                }
            } else {
                Log.e("TokenList", "Invalid Token")
            }
        }

        recyclerView = findViewById(R.id.messageRV)
        messageAdapter = MessageAdapter(this, messageList)
        recyclerView.adapter = messageAdapter
        recyclerView.layoutManager = LinearLayoutManager(this)

        getMessageList(chatRoomId!!)
        Log.d("MessageList", messageList.toString())

        inviteBtn.setOnClickListener {
            val intent = Intent(this, InviteActivity::class.java)
            intent.putExtra("chatRoomId", chatRoomId)
            intent.putExtra("chatRoomName", chatRoomName)
            startActivity(intent)
        }

        val participantBtn = findViewById<ImageView>(R.id.participants)
        participantBtn.setOnClickListener {
            val intent = Intent(this, ParticipantsActivity::class.java)
            intent.putExtra("chatRoomId", chatRoomId)
            startActivity(intent)
        }

        val message = findViewById<TextInputEditText>(R.id.message)
        val sendBtn = findViewById<ImageView>(R.id.send)

        lateinit var myNickName : String
        lateinit var myProfileUrl : String
        lateinit var messageModel: MessageModel
        val myUid = FirebaseAuthUtils.getUid()

        sendBtn.setOnClickListener {
            val contents = message.text.toString()
            if(contents.isEmpty()) {
                Toast.makeText(this, "메시지를 입력해주세요", Toast.LENGTH_SHORT).show()
            }
            else {
                val sendTime = getSendTime()
                CoroutineScope(Dispatchers.IO).launch {
                    val response = getUserInfo(myUid)
                    if (response.isSuccess) {
                        myNickName = response.result?.nickName.toString()
                        myProfileUrl = response.result?.imgUrl.toString()
                        val readerUids = mutableMapOf<String, Boolean>()
                        messageModel = MessageModel(myUid, myNickName, myProfileUrl, contents,
                            sendTime, readerUids, 0)
                        Log.d("readerUid", readerUids.size.toString())
                        FirebaseRef.message.child(chatRoomId!!).push().setValue(messageModel)

                        val connectedUidsRef = FirebaseRef.connected.child(chatRoomId!!)
                        val connectedUidsList: MutableList<String> = mutableListOf() // 현재 접속 중인 유저의 UID 목록

                        connectedUidsRef.addListenerForSingleValueEvent(object : ValueEventListener {
                            override fun onDataChange(dataSnapshot: DataSnapshot) {
                                for (childSnapshot in dataSnapshot.children) { // 난수 값
                                    val originalString = childSnapshot.value.toString()
                                    Log.d("originalString", originalString)
                                    // originalString이 {sRuRu1YVJMSj4csAUPOmaFfKfWJ2=true}와 같이 나오므로,
                                    // 앞에서 1글자, 뒤에서 6글자를 제거해 UID만 추출
                                    val modifiedString = originalString.substring(1, originalString.length - 6)
                                    Log.d("modifiedString", modifiedString)
                                    connectedUidsList.add(modifiedString)
                                    Log.d("connectedUidsList", connectedUidsList.toString())
                                }

                                CoroutineScope(Dispatchers.IO).launch {
                                    val response = getUserUidList(chatRoomId!!) // 채팅방에 참여한 모든 유저의 UID 목록
                                    Log.d("userUidList", response.toString())
                                    if (response.isSuccess) {
                                        val uidList = response.result!!
                                        Log.d("userUidList", response.result.toString())
                                        Log.d("userUidList", uidList.toString())

                                        val difference = uidList.subtract(connectedUidsList) // 차집합 List
                                        Log.d("difference", connectedUidsList.toString())
                                        Log.d("difference", difference.toString())
                                        val deviceTokenList = mutableListOf<String>()

                                        val addList = difference.map { uid ->
                                            async(Dispatchers.IO) {
                                                val response = getDeviceTokenByUid(uid)
                                                if (response.isSuccess) {
                                                    deviceTokenList.add(response.result.toString())
                                                } else {
                                                    Log.d("ChatRoomActivity", "디바이스 토큰 정보를 불러오지 못함")
                                                }
                                            }
                                        }

                                        // 모든 작업이 완료될 때까지 대기
                                        addList.awaitAll()

                                        Log.d("deviceTokenList", deviceTokenList.toString())
                                        val noticeModel = NoticeModel(myNickName, contents)
                                        for (token in deviceTokenList) {
                                            val pushNotice = PushNotice(noticeModel, token)
                                            Log.d("Push", pushNotice.toString())
                                            Log.d("Push", tokenList.toString())
                                            createNotificationChannel()
                                            pushNotification(pushNotice)
                                        }
                                    } else {
                                        Log.d("userUidList", "유저의 정보를 불러오지 못함")
                                    }
                                }
                            }

                            override fun onCancelled(databaseError: DatabaseError) {
                                // 처리 중 오류 발생 시 처리
                            }
                        })
                    } else {
                        Log.d("ChatRoomActivity", "유저의 정보를 불러오지 못함")
                    }
                }
                message.text?.clear()
            }
        }
    }

    override fun onPause() {
        super.onPause()
        val uid = FirebaseAuthUtils.getUid() // 현재 사용자의 UID
        val connectedUidsRef = FirebaseRef.connected.child(chatRoomId)

        connectedUidsRef.addListenerForSingleValueEvent(object : ValueEventListener {
            override fun onDataChange(dataSnapshot: DataSnapshot) {
                for (childSnapshot in dataSnapshot.children) {
                    // 각 난수 키에 대한 데이터를 가져옵니다.
                    val randomKeyData = childSnapshot.child(uid)
                    if (randomKeyData.exists()) {
                        connectedUidsRef.child(childSnapshot.key!!).child(uid).removeValue()
                    }
                }
            }

            override fun onCancelled(databaseError: DatabaseError) {
                // 처리 중 오류 발생 시 처리
            }
        })
    }

    override fun onRestart() {
        super.onRestart()
        connectedUids[FirebaseAuthUtils.getUid()] = true
        FirebaseRef.connected.child(chatRoomId!!).setValue(connectedUids)
    }

    override fun onBackPressed() {
        val uid = FirebaseAuthUtils.getUid() // 현재 사용자의 UID
        val connectedUidsRef = FirebaseRef.connected.child(chatRoomId)

        connectedUidsRef.addListenerForSingleValueEvent(object : ValueEventListener {
            override fun onDataChange(dataSnapshot: DataSnapshot) {
                for (childSnapshot in dataSnapshot.children) {
                    // 각 난수 키에 대한 데이터를 가져옵니다.
                    val randomKeyData = childSnapshot.child(uid)
                    if (randomKeyData.exists()) {
                        connectedUidsRef.child(childSnapshot.key!!).child(uid).removeValue()
                    }
                }
            }

            override fun onCancelled(databaseError: DatabaseError) {
                // 처리 중 오류 발생 시 처리
            }
        })
        val intent = Intent(this, MainActivity::class.java)
        startActivity(intent)
    }

    private suspend fun getUserCount(roomId: String) : BaseResponse<String> {
        return RetrofitInstance.chatApi.getUserCount(roomId)
    }

    private suspend fun getUserInfo(uid: String): BaseResponse<GetUserRes> {
        return RetrofitInstance.myPageApi.getUserInfo(uid)
    }

    private suspend fun getDeviceTokenByUid(uid: String): BaseResponse<String> {
        return RetrofitInstance.userApi.getDeviceTokenByUid(uid)
    }

    //메시지 보낸 시각 정보 반환
    @RequiresApi(Build.VERSION_CODES.O)
    private fun getSendTime(): String {
        try {
            val localDateTime = LocalDateTime.now()
            val dateTimeFormatter = DateTimeFormatter.ofPattern("M/d  a h:mm")
            return localDateTime.format(dateTimeFormatter)
        } catch (e: Exception) {
            e.printStackTrace()
            throw Exception("시간 정보를 불러오지 못함")
        }
    }

    private fun getMessageList(chatRoomId: String) {
        val postListener = object : ValueEventListener {
            override fun onDataChange(dataSnapshot: DataSnapshot) {
                messageList.clear()
                val newMessages = mutableListOf<MessageModel>() // 새로운 메시지를 저장할 리스트 생성

                for (dataModel in dataSnapshot.children) {
                    val messageModel = dataModel.getValue(MessageModel::class.java)
                    val messageId = dataModel.key
                    if (messageModel != null) {
                        if (messageId != null) {
                            // connectedUids로 업데이트
                            FirebaseRef.message.child(chatRoomId!!).child(messageId).child("readerUids")
                                .updateChildren(connectedUids as Map<String, Boolean>)
                                .addOnCompleteListener { readerUidTask ->
                                    if (readerUidTask.isSuccessful) {
                                        CoroutineScope(Dispatchers.IO).launch {
                                            val response = getUserUidList(chatRoomId!!)
                                            Log.d("userUidList", response.toString())
                                            if (response.isSuccess) {
                                                val newUidList = response.result!!
                                                Log.d("newUidList", response.result.toString())
                                                Log.d("newUidList", newUidList.toString())
                                                val keysList: List<String> = ArrayList<String>(messageModel.readerUids.keys)
                                                val intersection = newUidList.intersect(keysList)
                                                CoroutineScope(Dispatchers.IO).launch {
                                                    val response = getUserCount(chatRoomId!!)
                                                    Log.d("userCount", response.toString())
                                                    if (response.isSuccess) {
                                                        val count = response.result.toString()
                                                        val unreadUserCount = count.toInt() - intersection.size
                                                        Log.d("unreadUserCount", "1. " + count)
                                                        Log.d("unreadUserCount", "2. " + intersection.size.toString())
                                                        FirebaseRef.message.child(chatRoomId!!).child(messageId).child("unreadUserCount")
                                                            .setValue(unreadUserCount)
                                                    } else {
                                                        Log.d("UserListFragment", "유저의 정보를 불러오지 못함")
                                                    }
                                                }
                                            } else {
                                                Log.d("newUidList", "유저의 정보를 불러오지 못함")
                                            }
                                        }
                                    }

                                    else {
                                        Log.d("ChatRoomActivity", "reader UID를 업데이트하지 못함")
                                    }
                                }
                        }

                        if (messageModel.senderUid != FirebaseAuthUtils.getUid()) {
                            messageModel.viewType = MessageModel.VIEW_TYPE_YOU
                            if(!connectedUids.containsKey(FirebaseAuthUtils.getUid())) {

                            }
                        }
                        newMessages.add(messageModel)
                    }
                }

                messageList.addAll(newMessages)
                messageAdapter.notifyDataSetChanged()
                Log.d("MessageList", messageList.toString())

                recyclerView.post {
                    recyclerView.scrollToPosition(recyclerView.adapter?.itemCount?.minus(1) ?: 0)
                }
            }

            override fun onCancelled(databaseError: DatabaseError) {
                Log.w("MyMessage", "onCancelled", databaseError.toException())
            }
        }
        FirebaseRef.message.child(chatRoomId).addValueEventListener(postListener)
    }

    private suspend fun getUserStrList(roomId: String): BaseResponse<String> {
        return RetrofitInstance.chatApi.getUserStrList(roomId)
    }

    private suspend fun getTokenList(accessToken : String, roomId: String): BaseResponse<List<String>> {
        return RetrofitInstance.chatApi.getTokenList(accessToken, roomId)
    }

    private suspend fun getUserUidList(roomId: String): BaseResponse<List<String>> {
        return RetrofitInstance.chatApi.getUserUidList(roomId)
    }

    private fun createNotificationChannel() {
        // Create the NotificationChannel, but only on API 26+ because
        // the NotificationChannel class is new and not in the support library
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            val name = "name"
            val descriptionText = "description"
            val importance = NotificationManager.IMPORTANCE_DEFAULT
            val channel = NotificationChannel("test", name, importance).apply {
                description = descriptionText
            }
            // Register the channel with the system
            val notificationManager: NotificationManager =
                getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
            notificationManager.createNotificationChannel(channel)
        }
    }

    private fun pushNotification(notification: PushNotice) = CoroutineScope(Dispatchers.IO).launch {
        RetrofitInstance.noticeApi.postNotification(notification)
    }

    private fun getAccessToken(callback: (String) -> Unit) {
        val postListener = object : ValueEventListener {
            override fun onDataChange(dataSnapshot: DataSnapshot) {
                val data = dataSnapshot.getValue(com.chrome.chattingapp.authentication.UserInfo::class.java)
                val accessToken = data?.accessToken ?: ""
                callback(accessToken)
            }

            override fun onCancelled(databaseError: DatabaseError) {
                Log.w("ChatRoomActivity", "onCancelled", databaseError.toException())
            }
        }

        FirebaseRef.userInfo.child(FirebaseAuthUtils.getUid()).addListenerForSingleValueEvent(postListener)
    }
}

이제 코드를 실행시켜보자. 채팅방에 접속하면 Firebase의 Realtime Database의 connected에 본인의 UID가 올라가고, 채팅방을 나오면 본인의 UID가 삭제될 것이다.

채팅방에 접속해있을 때에는 새로운 메시지가 도착해도 Push 알림은 전송되지 않고, 채팅방이 아닌 다른 화면에 있거나 앱이 실행 중이지 않을 경우에만 Push 알림이 도착한다.

profile
Java Spring, Android Kotlin, Node.js, ML/DL 개발을 공부하는 인하대학교 정보통신공학과 학생입니다.

0개의 댓글