이번 포스팅에서는 채팅방을 나갈 수 있는 기능을 추가해보겠습니다.
채팅방 목록에서 채팅방을 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에서도 사라져야 하며, 채팅방의 참여자 목록과 참여자 수에도 변경사항이 반영되어야 한다.
이제 문제가 하나 생길 것이다. 메시지를 읽지 않은 유저의 숫자를 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에 속하지 않기 때문에 계산에서 제외된다.
① 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;
}
① ChatApi에 아래의 API를 추가한다.
@GET("/chat/uidList/{roomId}")
suspend fun getUserUidList(
@Path("roomId") roomId : String
) : BaseResponse<List<String>>
② ChatRoomActivity를 아래와 같이 수정한다.
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가 읽지 않은 것을 정확히 계산하고 있다.
이전 포스팅에서 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() 메서드를 호출한다.
① 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();
}
① UserApi에 아래의 API를 추가한다.
@GET("/users/uidToToken")
suspend fun getDeviceTokenByUid(
@Query("uid") uid : String
) : BaseResponse<String>
② FirebaseRef에 아래의 경로를 추가한다.
val connected = database.getReference("connected")
③ ChatRoomActivity의 onCreate 콜백 메서드에서 현재 채팅방에 존재하는 유저의 uid를 파이어베이스에 업로드하는 로직을 정의한다.
val connectedUids = mutableMapOf<String, Boolean>() // 현재 채팅방에 입장한 유저의 UID 맵
...
connectedUids[FirebaseAuthUtils.getUid()] = true
FirebaseRef.connected.child(chatRoomId!!).push().setValue(connectedUids)
④ Back 버튼에 대한 클릭 이벤트 리스너도 아래와 같이 수정한다.
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 콜백 메서드도 정의해야 한다.
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를 아래와 같이 수정한다.
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 알림이 도착한다.