Android로 채팅 구현 시 필요한 알림 기능을 Firebase를 활용하여 구현하려고 한다.
먼저 생각해보자.
1. 앱은 보통 계속 켜져있지 않다.
2. 그렇다면 알림은 어떻게 받는 것일까?
3. 백그라운드에 앱을 계속 켜놔야하는건가?
예전에 공부했던 Android 앱의 구성요소가 생각났다.
activity: 화면을 구성하는 요소이며 제일 친근하다.
broadcast receiver: 시스템의 알림을 받는다. 예전에 GPS가 필요할때 이용했었다.
content provider: 필요한 데이터를 제공하고 관리한다는데 이용한다는데 사용해본 적은 없다.
service: 앱이 백그라운드에서 실행하기 위한 진입점
-> service를 이용하면 되겠다.
이를 확인하기 위해 FirebaseMessagingService의 부모를 따라올라가니 Service가 있다.
FirebaseMessagingService를 상속한 클래스를 만든다.
class FbMessagingService: FirebaseMessagingService() {
private val instance = FirebaseMessaging.getInstance()
private lateinit var deviceToken: String
private val serverCommunicator = ServerCommunicator()
init {
instance.token.addOnCompleteListener(OnCompleteListener { task ->
if (!task.isSuccessful) {
Log.w("FireBase", "Fetching FCM registration token failed", task.exception)
return@OnCompleteListener
}
// Get new FCM registration token
deviceToken = task.result
})
}
override fun onNewToken(token: String) {
super.onNewToken(token)
//serverCommunicator.sendDeviceTokenToServer(token)
}
}
instance.token.addOnCompleteListener
-> 내 device 토큰을 가져온다.
여기서 토큰이란 device를 식별하기 위한 id로 Firebase sdk가 generating한다. 이를 앱 서버에 보내어 사용자 Table에 저장하고 특정 로직(ex. 앱 시작) 때 토큰을 업데이트하여 불필요한 리소스를 만들지 않게 하는 것이 권장사항이다.
onNewToken(token: String)
-> 새 토큰이 generating 되었을때 호출되며 이를 이용하여 앱 서버 토큰을 업데이트하면 되겠다!
AndroidManifest.xml에 다음을 추가하여 자동 초기화를 막는다.
<meta-data
android:name="firebase_messaging_auto_init_enabled"
android:value="false" />
<meta-data
android:name="firebase_analytics_collection_enabled"
android:value="false" />
-> 굳이 왜 자동 초기화 기능을 만들었을까? 지금은 잘 모르겠다...
Firebase의 Messeaging test를 이용하여 보낸 알림이 잘 도착한다.
하지만
Missing Default Notification Channel metadata in AndroidManifest. Default value will be used.
경고가 뜬다. 알아보니 알림은 앱마다 채널이 있어야하며 이를 따로 설정해주지 않았기 때문에 default channel을 쓴다는 것이다.
MainActivity.kt
private fun createNotificationChannel() {
val channelName = "Firebase Test"
val channelDescription = "테스트"
val channelId = resources.getString(R.string.default_notification_channel_id)
val channel =
NotificationChannel(channelId, channelName, NotificationManager.IMPORTANCE_DEFAULT)
channel.description = channelDescription
val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
notificationManager.createNotificationChannel(channel)
}
위 함수 추가 후 onCreate 에서 호출
AndroidManifest.xml
<meta-data
android:name="com.google.firebase.messaging.default_notification_channel_id"
android:value="@string/default_notification_channel_id" />
추가
그럼 이제 서비스에서 메시지를 수신했을때 로직을 구현해보자
FirebaseMessagingService의 onMessageReceived(message: RemoteMessage)를 override하여 이용한다
하지만 onMessageReceived가 백그라운드에서 알림을 받지 못함.
-> RemoteMessage의 payload에 notification 필드가 있을 경우 device 시스템이 처리하게 됨. 따라서 이를 삭제하고 data 필드를 생성하여 정보를 넣어야한다.
-> postman과 google developer를 이용하여 payload 수정하고 통신까지 성공!! 하지만 알람이 안 온다...
여러 방법을 찾아보고 생각해보느라 시간이 다 갔다...😅
방법 1)
알람이 안오기 때문에 notification 필드를 추가
-> 알림을 커스텀하기 힘들다.
방법 2) data를 추가하고 onMessageRecieved 에서 직접 알림을 커스터마이징한다.
방법2를 채택하자!
다음 코드를 FbMessagingService.kt 에 추가
override fun onMessageReceived(message: RemoteMessage) {
super.onMessageReceived(message)
showNotification(message.data["body"]!!)
}
private fun showNotification(message: String) {
val channelId = getString(R.string.default_notification_channel_id)
val i = Intent(this, MainActivity::class.java)
i.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
val pendingIntent = PendingIntent.getActivity(this, 0, i, PendingIntent.FLAG_UPDATE_CURRENT)
val builder = NotificationCompat.Builder(this, channelId)
.setAutoCancel(true)
.setContentTitle("FCM Test")
.setContentText(message)
.setSmallIcon(R.drawable.ic_launcher_background)
.setContentIntent(pendingIntent)
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
manager.notify(0, builder.build())
}
intet.addFlags에 대한 자세한 설명)
https://medium.com/@logishudson0218/intent-flag%EC%97%90-%EB%8C%80%ED%95%9C-%EC%9D%B4%ED%95%B4-d8c91ddd3bfc
요약: 해당 activity를 백스택에 최상위로 올라오게 한다.
예시: abc -> b 호출 -> c pop() -> b의 onCreate()부터
잘 작동한다. 그리고 intent로 시작하였기 때문에 putExtra를 이용할 수 있을 것이다.
함수가 받는 파라미터를 map으로 바꾸었다. 위에서 언급한 Data 필드가 Map 형태로 받기 때문이다.
private fun showNotification(dataMap: Map<String, String>) {
val channelId = getString(R.string.default_notification_channel_id)
val intent = Intent(this, MainActivity::class.java)
intent.putExtra("sender", dataMap["sender"])
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
val pendingIntent = PendingIntent.getActivity(this, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT)
val builder = NotificationCompat.Builder(this, channelId)
.setAutoCancel(true)
.setContentTitle(dataMap["title"])
.setContentText(dataMap["body"])
.setSmallIcon(R.drawable.ic_launcher_background)
.setContentIntent(pendingIntent)
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
manager.notify(0, builder.build())
}
그리고 MainActivity에서 from 데이터를 출력하는 코드를 작성한 후
val sender = intent.getStringExtra("sender")
Log.i("Firebase", "We got Message from $sender")
테스트!
중간에 data["sender"]가 아닌 from 으로 했었는데 오류가 났었다... doc을 찾아보니
예약어가 있었다.
다음 날에는 이를 Flag로 이용하여 채팅 방으로 바로 갈 수 있게 만들면 되겠다!
생각해보니 단체 채팅도 지원해야 한다. 1대1을 위한 기기 등록 토큰은 물론 topic을 이용하는 것도 필요하다.
1. 앱에서 채팅방 생성하기 클릭 후 초대할 user 선택
2. 서버에 createChattingRoom 요청
Android Studio에서 해야할 일은 없는것 같다.
data class ChatMessage(
val sender: String,
val title: String,
val body: String,
val date: LocalDateTime,
val roomId: Long
)
① onMessageReceived에서...
1. title과 body는 무조건 필요하다. 그치만 service에서 알림을 만들땐 title에 sender를 넣어 발신자를 보여줄 것이다.
2. '읽지 않은 메시지'를 표현하기 위해 받은 ChatMessage를 RoomDB에서 관리
1번은 간단히 고칠 수 있었고 2번을 기록하겠다!
RoomDataBase를 구현해보자!
스레드 동기화를 제공하기 위해 Singletone 으로 디자인하였다.
@Database(
entities = [NotReadMessage::class],
version = 1,
exportSchema = false
)
abstract class MoyeobaRoomDataBase: RoomDatabase() {
abstract val roomDao: RoomDataBaseDao
companion object {
@Volatile
private var INSTANCE: MoyeobaRoomDataBase? = null
fun getInstance(context: Context): MoyeobaRoomDataBase {
synchronized(this) {
var instance = INSTANCE
if(instance == null) {
instance = Room.databaseBuilder(
context.applicationContext,
MoyeobaRoomDataBase::class.java,
"sleep history database"
)
.fallbackToDestructiveMigration()
.build()
INSTANCE = instance
}
return instance
}
}
}
}
Entity인 NotReadMessage
ChatMessage로부터 바로 생성가능하도록 부 생성자를 추가했다.
@Entity
data class NotReadMessage(
@PrimaryKey
val id: Long,
val roomId: Long,
val sender: String,
val body: String,
) {
constructor(chatMessage: ChatMessage) : this(
id = chatMessage.id.toLong(),
roomId = chatMessage.roomId,
sender = chatMessage.sender,
body = chatMessage.body
)
}
마지막으로 DAO!
@Dao
interface RoomDataBaseDao {
@Insert
fun insertNotReadMsg(notReadMessage: NotReadMessage)
@Delete
fun deleteNotReadMsg(notReadMessage: NotReadMessage)
}
이를 사용해보자
FbMessagingService의 init 블럭을 onCreate로 바꾸었다.
room을 이용하여 저장한다.
override fun onMessageReceived(message: RemoteMessage) {
super.onMessageReceived(message)
val data = message.data
val receivedMessage = ChatMessage(
id = data["id"]!!.toLong(),
sender = data["sender"]!!,
body = data["body"]!!,
title = data["title"]!!,
date = LocalDateTime.parse(data["date"]!!),
roomId = data["roomId"]!!.toLong(),
)
roomDB.roomDao.insertNotReadMsg(NotReadMessage(receivedMessage))
showNotification(receivedMessage)
}
이제 채팅 탭에 들어가면 NotReadMessage들을 불러와서 채팅방 위에 안 읽은 메시지 개수를 표시해주면 될것같다.
② MainActivity.onCreate()에서...
메시지 알림을 눌러서 들어왔을 경우, 위 PendingIntent에 할당한 roomId를 이용하여 바로 채팅방으로 입장
Jetpack Compose로 프로젝트를 진행했었다. bottom navigation을 이용하여 각 탭마다 이용하던 프래그먼트를 저장할 수 있도록 하였다.
참고)
Git:
https://github.com/vinchamp77/Demo_SimpleNavigationCompose/blob/master/app/src/main/java/com/example/simplenavigationcompose/ui/screens/LoginScreen.kt
blog:
https://snow.dog/blog/android-jetpack-compose-saving-bottombar-tabs
그러면 해야할것은
1. 알림에서 보낸 intent에 접근하여 roomId의 유무 확인.
2. 값이 있으면 flag(bottom navigation item index)를 채팅리스트 탭으로 설정하여 이동하게 한다.
3. 바로 채팅리스트 화면으로 이동 이후 intent로 접근하여 roomId를 가져와 해당 채팅방을 연다.
roomId를 intent로 부터 얻어 알림으로 실행한 경우가 아니면 0을 할당해준다.
flag 를 선언하여 Home으로 갈지 ChatList로 갈지 정해준다.
val roomId = intent.getLongExtra("roomId", 0)
val flag = if(roomId == 0L) BottomDestination.HOME_FEATURE.ordinal
else BottomDestination.CHAT_FEATURE.ordinal
Log.i("Firebase", "We got Message going to $roomId")
setContent {
SetView(flag)
}
@Composable
fun SetView(flag: Int) {
CustomNavigation(flag)
}
CustomNavigation에 넘겨준 flag를 이용하여 bottomBar.pagerState.initialPage = flag로 설정한다.
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun rememberBottomBar(flag: Int): BottomBar {
val navData = buildMap {
BottomDestination.entries.forEach {
put(it, rememberNavController())
}
}
val pagerState = rememberPagerState(
initialPage = flag,
initialPageOffsetFraction = 0f
) {
navData.size
}
with(pagerState) {
Log.d("Navigate", "initial: $initialPage, pageCount: $pageCount")
}
return remember {
BottomBar(
pagerState = pagerState,
navData = navData,
)
}
}
잘 작동한다!!
ChatListFragment.kt
val activity = LocalContext.current.getActivity()
val intent = activity?.intent
val roomId = intent?.getLongExtra("roomId", 0L): 0L
if(roomId != 0L) {
//TODO: find roomId
// if find -> navigate to ChatRoomFragment()
// else -> load ChatRoom from server and navigate to ChatRoomFragment()
openChatRoom(roomId) //test를 위한 임시코드
}
아래는 Context 확장 함수를 정의한 코드다.
fun Context.getActivity(): Activity? = when (this) {
is Activity -> this
is ContextWrapper -> baseContext.getActivity()
else -> null
}
openChatRoom의 작동을 살펴보자.
navigate는 복잡한 data를 인자로 받는 것을 피한다. 그렇기 때문에 navigate route에 간단한 정보를 넣는 방식으로 data를 전달한다.
composable(route = Destination.ChatList.Main.route) {
ChatListFragment (
openChatRoom = { roomId ->
navController.navigate(route = Destination.ChatList.ChatRoom.route+roomId) {
popUpTo(navController.graph.id) {inclusive=false}
}})
}
roomId를 뒤에 붙여주었다.
그렇다면 destination은 어떻게 받을까??
composable(
route = Destination.ChatList.ChatRoom.route+"{roomId}",
arguments = listOf(navArgument("roomId") { type = NavType.LongType })) {
val roomId = it.arguments?.getLong("roomId") ?: 0L
ChatRoomFragment(roomId)
}
arguments에서 roomId를 key값으로 받아온다. 상당히 복잡한 모습을 띄는데 ViewModel을 권장하는건가 싶다. 아무튼 이를 ChatRoomFragment에서 받아오면!
성공!