카카오톡 같은 메신저 프로그램의 경우
내가 메시지를 보내면 수신자에게 알림이 전송되는 것을 알 수 있다.
레트로핏을 사용하여 서버와 통신해주면, FCM 알림도 이와 같이 전송할 수 있다.
2021.06.02 - [안드로이드/파이어베이스] - [Android] 파이어베이스 FCM 푸시 알림 구현하기
우선 FCM을 수신할 수 있는 환경을 만들어야 한다.
2021.05.14 - [안드로이드/AAC, MVVM] - [Android] Retrofit 사용하여 서버와 http 통신하기 - (1) 기본적인 사용법
여기에선 레트로핏을 사용할 것이므로
레트로핏에 대해 잘 모른다면 이 글을 참고할 수 있다.
자세한 내용은 공식문서에서 확인할 수 있다.
위의 공식문서에서 확인해보면,
FCM 서버 프로토콜은
HTTP v1, HTTP, XMPP 3가지의 방식을 지원한다.
HTTP v1이 가장 최신 프로토콜으로, 여러 플랫폼에 메시지를 보낼 수 있으며 보안도 더 강화된 프로토콜이다.
하지만 그만큼 사용하기 어렵기 때문에 이 글에서는
기존의 방식인 HTTP 프로토콜을 사용할 것이다.
https://firebase.google.com/docs/cloud-messaging/http-server-ref?hl=ko
// Retrofit
implementation 'com.squareup.retrofit2:retrofit:2.9.0'
// Coroutines
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.4.2'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.4.1'
파이어베이스, FCM은 이미 추가했다고 가정하여 생략하고,
통신을 위해 레트로핏과 코루틴을 추가해준다.
푸시 메시지를 보내기 위해서는
상대방의 토큰을 알아야한다.
즉, 유저들의 토큰을 서버에 저장해두어야 한다.
// 프로필 불러오기
fireStore.collection("users").document(uid)
.addSnapshotListener { documentSnapshot, _ ->
if (documentSnapshot == null) return@addSnapshotListener
val userDTO = documentSnapshot.toObject(UserDTO::class.java)
if (userDTO?.userId != null) {
// 토큰이 변경되었을 경우 갱신
if(userDTO.token != token){
Log.d(TAG, "profileLoad: 토큰 변경되었음.")
val newUserDTO = UserDTO(userDTO.uId,userDTO.userId,
userDTO.imageUri,userDTO.score,userDTO.sharing,userDTO.area,token)
fireStore.collection("users").document(uid).set(newUserDTO)
// 유저정보 라이브데이터 변경하기
this.userDTO.value = newUserDTO
}
// 아니면 그냥 불러옴
else {
Log.d(TAG, "profileLoad: 이미 동일한 토큰이 존재함.")
this.userDTO.value = userDTO!!
}
}
필자는 유저가 로그인할때마다 유저 정보를 모아놓은 firestore의 컬렉션에서 토큰이 갱신되어 저장되도록 하였다.
토큰은 위와 같은 상황에서 변경되므로 로그인할때마다 토큰의 변경사항이 있는지 확인하여 저장하도록 하였다.
또는 다른 방법으로
https://firebase.google.com/docs/cloud-messaging/android/client?hl=ko
여기에서 나온 방식대로
FirebaseMessagingService의 onNewToken 메소드는 새로운 토큰이 생성될 때마다 호출되므로
이 메소드에서 토큰을 저장하는 방법도 있다.
data class NotificationBody(
val to: String,
val data: NotificationData
) {
data class NotificationData(
val title: String,
val userId : String,
val message: String
)
}
이제 통신을 위한 모델을 정의해준다.
Body에는 to와 data 파라미터를 사용한다.
to는 푸시이벤트를 받는 상대방(token), data는 푸시의 내용을 담을 것이다.
여기에서는 제목, 유저이름, 내용의 3가지 내용만 담도록 하였다.
fcm은 이곳으로 보내야 한다. 따라서
class Constants {
companion object {
// FCM URL
const val FCM_URL = "https://fcm.googleapis.com"
}
}
별도의 상수를 관리하는 클래스에서 FCM의 URL을 선언해주고
interface FcmInterface {
@POST("fcm/send")
suspend fun sendNotification(
@Body notification: NotificationBody
) : Response<ResponseBody>
}
푸시메시지를 서버로 보내는 동작을 할 인터페이스를 정의해준다.
서버 통신은 비동기처리를 해야하므로 코루틴을 사용하기 위하여 suspend 처리해준다.
object RetrofitInstance {
private val retrofit by lazy {
Retrofit.Builder()
.baseUrl(FCM_URL)
.client(provideOkHttpClient(AppInterceptor()))
.addConverterFactory(GsonConverterFactory.create())
.build()
}
val api : FcmInterface by lazy {
retrofit.create(FcmInterface::class.java)
}
// Client
private fun provideOkHttpClient(
interceptor: AppInterceptor
): OkHttpClient = OkHttpClient.Builder()
.run {
addInterceptor(interceptor)
build()
}
// 헤더 추가
class AppInterceptor : Interceptor {
@Throws(IOException::class)
override fun intercept(chain: Interceptor.Chain)
: Response = with(chain) {
val newRequest = request().newBuilder()
.addHeader("Authorization", "key=$FCM_KEY")
.addHeader("Content-Type", "application/json")
.build()
proceed(newRequest)
}
}
}
레트로핏을 생성해준다.
여기서 URL로는 아까 선언해준 FCM 의 URL을 사용하고, 인증 토큰이 필요하기때문에 별도의 헤더를 추가해주어야 한다.
FCM_KEY는 자신의 파이어베이스 프로젝트에서 클라우드 메시징의 서버키를 추가해주면 된다.
이제 레트로핏 api의 인터페이스를 호출하기만 하면 된다.
class FirebaseRepository() {
val myResponse : MutableLiveData<Response<ResponseBody>> = MutableLiveData() // 메세지 수신 정보
// 푸시 메세지 전송
suspend fun sendNotification(notification: NotificationBody) {
myResponse.value = RetrofitInstance.api.sendNotification(notification)
}
}
레포지토리에서
응답을 확인할 라이브데이터를 정의해주고
메소드를 정의하여 아까 만든 api를 호출한다고 정의해주고,
class FirebaseViewModel(application: Application) : AndroidViewModel(application) {
private val repository : FirebaseRepository = FirebaseRepository()
val myResponse = repository.myResponse
// 푸시 메세지 전송
fun sendNotification(notification: NotificationBody) {
viewModelScope.launch {
repository.sendNotification(notification)
}
}
}
뷰모델에서 코루틴으로 호출해준다.
이제 푸시메시지를 전송할 메인 코드에서
// 유저 정보 불러옴
fireStore.collection("users").document(otherUId).get()
.addOnCompleteListener { documentSnapshot->
if(documentSnapshot.isSuccessful){
val userDTO = documentSnapshot.result.toObject(UserDTO::class.java)
// 리사이클러뷰 어댑터 연결
chatAdapter = ChatAdapter(uId.toString(),userDTO!!)
messageRecyclerView.adapter = chatAdapter
userText.text = userDTO.userId
token = userDTO.token.toString()
}
}
아까 서버에 저장해놓은 상대방의 토큰 정보를 불러와서
// 메시지 전송
sendButton.setOnClickListener {
// 메세지 세팅
val time = System.currentTimeMillis()
val message = MessageDTO(uId,otherUId,messageEditView.text.toString(),time)
// 메세지 전송
firebaseViewModel.uploadChat(message)
// FCM 전송하기
val data = NotificationBody.NotificationData(getString(R.string.app_name)
,curUserId,messageEditView.text.toString())
val body = NotificationBody(token,data)
firebaseViewModel.sendNotification(body)
// 전송 후 에디트뷰 초기화
messageEditView.setText("")
}
원하는 데이터를 넣어 전송해주면 이제 FCM이 전송되었다.
// 응답 여부
firebaseViewModel.myResponse.observe(this@ChattingActivity){
Log.d(TAG, "onViewCreated: $it")
}
라이브데이터를 확인해보면
통신이 잘되었는지, 오류가 발생했는지 코드로 확인할 수 있다.
https://firebase.google.com/docs/cloud-messaging/http-server-ref?hl=ko#error-codes
여기서 각 오류코드의 내용을 확인할 수 있다.
이제 수신측에서 수신하기 위하여
기존의 FirebaseMessagingService를 약간 수정해주어야 한다.
onMessageReceived 콜백에서 서버에서 직접 보냈을때와
방금처럼 유저가 보낸것을 수신했을때의 두가지로 나눠준다.
data의 원소들을 통해 아까 통신한 데이터들을 사용할 수 있다.
파라미터는 동일한 이름으로 사용하면 된다.
// 다른 기기에서 서버로 보냈을 때
@RequiresApi(Build.VERSION_CODES.P)
private fun sendMessageNotification(title: String,userId: String, body: String){
val intent = Intent(this, MainActivity::class.java)
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) // 액티비티 중복 생성 방지
val pendingIntent = PendingIntent.getActivity(this, 0 , intent,
PendingIntent.FLAG_ONE_SHOT) // 일회성
// messageStyle 로
val user: androidx.core.app.Person = Person.Builder()
.setName(userId)
.setIcon(IconCompat.createWithResource(this,R.drawable.ic_baseline_person_24))
.build()
val message = NotificationCompat.MessagingStyle.Message(
body,
System.currentTimeMillis(),
user
)
val messageStyle = NotificationCompat.MessagingStyle(user)
.addMessage(message)
val channelId = "channel" // 채널 아이디
val defaultSoundUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION) // 소리
val notificationBuilder = NotificationCompat.Builder(this, channelId)
.setContentTitle(title) // 제목
.setContentText(body) // 내용
.setStyle(messageStyle)
.setSmallIcon(R.drawable.ic_baseline_shopping_basket_24) // 아이콘
.setAutoCancel(true)
.setSound(defaultSoundUri)
.setContentIntent(pendingIntent)
val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
// 오레오 버전 예외처리
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(channelId,
"알림 메세지",
NotificationManager.IMPORTANCE_LOW) // 소리없앰
notificationManager.createNotificationChannel(channel)
}
notificationManager.notify(0 , notificationBuilder.build()) // 알림 생성
}
이제 기존처럼 노티피케이션을 처리해주면 된다.
여기선 메시지처럼 표현하기 위하여 messageStyle을 사용했다.
FirebaseMessagingService 전체코드
class MyFirebaseMessagingService : FirebaseMessagingService() {
// 메세지가 수신되면 호출
override fun onMessageReceived(remoteMessage: RemoteMessage) {
// 서버에서 직접 보냈을 때
if(remoteMessage.notification != null){
sendNotification(remoteMessage.notification?.title,
remoteMessage.notification?.body!!)
}
// 다른 기기에서 서버로 보냈을 때
else if(remoteMessage.data.isNotEmpty()){
val title = remoteMessage.data["title"]!!
val userId = remoteMessage.data["userId"]!!
val message = remoteMessage.data["message"]!!
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
sendMessageNotification(title,userId,message)
}
else{
sendNotification(remoteMessage.notification?.title,
remoteMessage.notification?.body!!)
}
}
}
// Firebase Cloud Messaging Server 가 대기중인 메세지를 삭제 시 호출
override fun onDeletedMessages() {
super.onDeletedMessages()
}
// 메세지가 서버로 전송 성공 했을때 호출
override fun onMessageSent(p0: String) {
super.onMessageSent(p0)
}
// 메세지가 서버로 전송 실패 했을때 호출
override fun onSendError(p0: String, p1: Exception) {
super.onSendError(p0, p1)
}
// 새로운 토큰이 생성 될 때 호출
override fun onNewToken(token: String) {
super.onNewToken(token)
sendRegistrationToServer(token)
}
// 서버에서 직접 보냈을 때
private fun sendNotification(title: String?, body: String){
val intent = Intent(this, MainActivity::class.java)
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) // 액티비티 중복 생성 방지
val pendingIntent = PendingIntent.getActivity(this, 0 , intent,
PendingIntent.FLAG_ONE_SHOT) // 일회성
val channelId = "channel" // 채널 아이디
val defaultSoundUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION) // 소리
val notificationBuilder = NotificationCompat.Builder(this, channelId)
.setContentTitle(title) // 제목
.setContentText(body) // 내용
.setSmallIcon(R.drawable.ic_baseline_shopping_basket_24) // 아이콘
.setAutoCancel(true)
.setSound(defaultSoundUri)
.setContentIntent(pendingIntent)
val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
// 오레오 버전 예외처리
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(channelId,
"Channel human readable title",
NotificationManager.IMPORTANCE_DEFAULT)
notificationManager.createNotificationChannel(channel)
}
notificationManager.notify(0 , notificationBuilder.build()) // 알림 생성
}
// 다른 기기에서 서버로 보냈을 때
@RequiresApi(Build.VERSION_CODES.P)
private fun sendMessageNotification(title: String,userId: String, body: String){
val intent = Intent(this, MainActivity::class.java)
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) // 액티비티 중복 생성 방지
val pendingIntent = PendingIntent.getActivity(this, 0 , intent,
PendingIntent.FLAG_ONE_SHOT) // 일회성
// messageStyle 로
val user: androidx.core.app.Person = Person.Builder()
.setName(userId)
.setIcon(IconCompat.createWithResource(this,R.drawable.ic_baseline_person_24))
.build()
val message = NotificationCompat.MessagingStyle.Message(
body,
System.currentTimeMillis(),
user
)
val messageStyle = NotificationCompat.MessagingStyle(user)
.addMessage(message)
val channelId = "channel" // 채널 아이디
val defaultSoundUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION) // 소리
val notificationBuilder = NotificationCompat.Builder(this, channelId)
.setContentTitle(title) // 제목
.setContentText(body) // 내용
.setStyle(messageStyle)
.setSmallIcon(R.drawable.ic_baseline_shopping_basket_24) // 아이콘
.setAutoCancel(true)
.setSound(defaultSoundUri)
.setContentIntent(pendingIntent)
val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
// 오레오 버전 예외처리
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(channelId,
"알림 메세지",
NotificationManager.IMPORTANCE_LOW) // 소리없앰
notificationManager.createNotificationChannel(channel)
}
notificationManager.notify(0 , notificationBuilder.build()) // 알림 생성
}
// 받은 토큰을 서버로 전송
private fun sendRegistrationToServer(token: String){
}
}
이제 푸시 메시지가 잘 수신되는 것을 확인할 수 있다.
https://github.com/HanYeop/Happy-Sharing
코드는 이 프로젝트에서 확인할 수 있다.
참고
https://youngest-programming.tistory.com/393