경험의 기록

카카오톡 같은 메신저 프로그램의 경우

내가 메시지를 보내면 수신자에게 알림이 전송되는 것을 알 수 있다.

레트로핏을 사용하여 서버와 통신해주면, FCM 알림도 이와 같이 전송할 수 있다.

 

2021.06.02 - [안드로이드/파이어베이스] - [Android] 파이어베이스 FCM 푸시 알림 구현하기

 

[Android] 파이어베이스 FCM 푸시 알림 구현하기

2021.04.11 - [안드로이드/파이어베이스] - [Android] 파이어베이스 연결 후 로그인 구현하기 [Android] 파이어베이스 연결 후 로그인 구현하기 1. 파이어베이스와 프로젝트 연결하기 firebase.google.com/?hl=ko.

hanyeop.tistory.com

우선 FCM을 수신할 수 있는 환경을 만들어야 한다.

 

 

2021.05.14 - [안드로이드/AAC, MVVM] - [Android] Retrofit 사용하여 서버와 http 통신하기 - (1) 기본적인 사용법

 

[Android] Retrofit 사용하여 서버와 http 통신하기 - (1) 기본적인 사용법

❓ 레트로핏이란 ▶ 서버와 HTTP 통신을 해서 받은 데이터를 앱에서 출력해서 보여주는 라이브러리 🔴 레트로핏 3가지 구성요소 Model(POJO) : DTO(Data Transfer Object). 서버로부터 JSON 형식으로 통

hanyeop.tistory.com

여기에선 레트로핏을 사용할 것이므로

레트로핏에 대해 잘 모른다면 이 글을 참고할 수 있다.

 

 

https://firebase.google.com/docs/cloud-messaging/server?hl=ko#implementing-http-connection-server-protocol 

 

서버 환경 및 FCM  |  Firebase

Google은 흑인 공동체를 위한 인종적 평등을 추구하기 위해 노력하고 있습니다. 자세히 알아보기 의견 보내기 서버 환경 및 FCM Firebase 클라우드 메시징의 서버 측 구성요소는 2가지입니다. Google에

firebase.google.com

자세한 내용은 공식문서에서 확인할 수 있다.

 

 


HTTP 프로토콜 

위의 공식문서에서 확인해보면,

FCM 서버 프로토콜은

HTTP v1, HTTP, XMPP 3가지의 방식을 지원한다.

HTTP v1이 가장 최신 프로토콜으로, 여러 플랫폼에 메시지를 보낼 수 있으며 보안도 더 강화된 프로토콜이다.

 

하지만 그만큼 사용하기 어렵기 때문에 이 글에서는

기존의 방식인 HTTP 프로토콜을 사용할 것이다.

 

https://firebase.google.com/docs/cloud-messaging/http-server-ref?hl=ko 

 

Firebase 클라우드 메시징 HTTP 프로토콜

firebase.ml.naturallanguage.translate

firebase.google.com

 

dependencies 추가

// 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 

 

Android에서 Firebase 클라우드 메시징 클라이언트 앱 설정

Google은 흑인 공동체를 위한 인종적 평등을 추구하기 위해 노력하고 있습니다. 자세히 알아보기 의견 보내기 Android에서 Firebase 클라우드 메시징 클라이언트 앱 설정 Firebase 클라우드 메시징 Android

firebase.google.com

여기에서 나온 방식대로

FirebaseMessagingService의 onNewToken 메소드는 새로운 토큰이 생성될 때마다 호출되므로

이 메소드에서 토큰을 저장하는 방법도 있다.

 

Model 정의

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가지 내용만 담도록 하였다.

 

Interface와 URL 정의

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 처리해준다.

 

Instance 정의

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

 

Firebase 클라우드 메시징 HTTP 프로토콜

firebase.ml.naturallanguage.translate

firebase.google.com

여기서 각 오류코드의 내용을 확인할 수 있다.

 

 

수신하기

이제 수신측에서 수신하기 위하여

기존의 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

 

GitHub - HanYeop/Happy-Sharing: 무료나눔 앱

무료나눔 앱. Contribute to HanYeop/Happy-Sharing development by creating an account on GitHub.

github.com

코드는 이 프로젝트에서 확인할 수 있다.

 

 

 

 

참고

https://youngest-programming.tistory.com/393

 

 

 

 

 

 

반응형

공유하기

facebook twitter kakaoTalk kakaostory naver band
loading