이전 FCM - PUSH 알림 받기 포스팅에서는 FCM을 사용하여 Firebase Console에서 메시지를 전송하고 PUSH 알림을 받는 예제를 만들었다.
본 포스팅에서는 안드로이드 앱에서 FCM을 사용하여 다른 사용자에게 메시지를 전송하는 예제를 만들어 보려고 한다.
Firebase 프로젝트 생성 및 연결 부분은 참고할 수 있는 다른 예제들도 많이 있기에 생략한다.
- 이번 예제를 요약하면 다음과 같다.
- 안드로이드 앱에서 Firebase 서버로 메시지를 전송한다.
- Firebase 서버에서 앱으로 메시지를 전송한다.
- 앱에서 메시지를 수신하고 수신 한 메시지를 알림으로 띄워준다.
- 서버 통신은 Retrofit을 사용한다.
build.gradle(앱 수준)에서 Firebase 인증, FCM, Coroutines, Retrofit Android 라이브러리의 종속 항목을 추가한다.
dependencies {
// Firebase
implementation platform('com.google.firebase:firebase-bom:31.3.0')
implementation 'com.google.firebase:firebase-analytics-ktx'
// Firebase cloud messaging
implementation 'com.google.firebase:firebase-messaging:23.0.3'
// Coroutines
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4")
// Retrofit
implementation 'com.squareup.retrofit2:retrofit:2.9.0'
implementation 'com.squareup.retrofit2:converter-gson:2.9.0'
}
📌 앱의 매니페스트에 FirebaseMessagingService를 확장하는 서비스 추가
이 서비스는 백그라운드에서 앱의 알림을 수신하는 것 외에 다른 방식으로 메시지를 처리하려는 경우에 필요하다. 포그라운드 앱의 알림 수신, 데이터 페이로드 수신, 업스트림 메시지 전송 등을 수행하려면 이 서비스를 확장해야 한다.
<manifest ...>
<uses-permission android:name="android.permission.INTERNET" />
<application ...>
<service
android:name=".fcm.MyFirebaseMessagingService"
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>
...
</application>
</manifest>
📌 Android 13(API 수준 33)에서는 앱에서 예외 없는 알림을 보내기 위한 새로운 런타임 권한 POST_NOTIFICATIONS를 도입했다.
앱의 매니페스트 파일에서 POST_NOTIFICATIONS 권한을 선언한다.
<manifest ...>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<application ...>
...
</application>
</manifest>
Android API 수준이 33 이상일 경우에만 권한의 허용 여부를 확인한다.
class FCMActivity : AppCompatActivity() {
private lateinit var binding: ActivityFCMBinding
private val PERMISSION_REQUEST_CODE = 5000
private fun permissionCheck() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
val permissionCheck = ContextCompat.checkSelfPermission(
this,
android.Manifest.permission.POST_NOTIFICATIONS
)
if (permissionCheck != PackageManager.PERMISSION_GRANTED) {
ActivityCompat.requestPermissions(
this,
arrayOf(android.Manifest.permission.POST_NOTIFICATIONS),
PERMISSION_REQUEST_CODE
)
}
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = DataBindingUtil.setContentView(this, R.layout.activity_fcm)
permissionCheck()
}
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<out String>,
grantResults: IntArray
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
when (requestCode) {
PERMISSION_REQUEST_CODE -> {
if (grantResults.isEmpty() || grantResults[0] != PackageManager.PERMISSION_GRANTED) {
Toast.makeText(applicationContext, "Permission is denied", Toast.LENGTH_SHORT)
.show()
} else {
Toast.makeText(applicationContext, "Permission is granted", Toast.LENGTH_SHORT)
.show()
}
}
}
}
}
먼저 상수를 관리 할 클래스를 생성하여 BASE_URL과 FCM SERVER_KEY 그리고 컨텐츠 타입을 선언한다.
BASE_URL의 경우 하단의 내용과 같이 해당 엔드포인트로 연결해야 하며 SERVER_KEY의 경우는 Firebase Console 프로젝트 설정의 클라우드 메시징에서 확인하여 선언해준다.
📌 기존 HTTP 프로토콜을 사용할 때는 앱 서버가 모든 HTTP 요청을 이 엔드포인트로 연결해야 한다.
👉🏻 https://fcm.googleapis.com/fcm/send
class Repository {
companion object {
const val BASE_URL = "https://fcm.googleapis.com"
const val SERVER_KEY = "YOUR_SERVER_KEY"
const val CONTENT_TYPE = "application/json"
}
}
👉🏻 파이어베이스 콘솔에서 프로젝트로 진입한 후 좌측 탭의 '프로젝트 개요 설정 > 프로젝트 설정' 을 클릭한다.
👉🏻 프로젝트 설정 화면에서 클라우드 메시징 탭을 클릭한다. 화면 하단의 'Cloud Messaging API(기존)'의 우측에 있는 메뉴 아이콘을 클릭하면 다음 화면과 같이 'Google Cloud Console에서 API 관리' 탭이 나타난다.
👉🏻 'Google Cloud Console에서 API 관리'를 클릭하면 다음과 같은 화면으로 이동하며 사용 버튼을 누른다.
👉🏻 사용 버튼을 누르고 위와 같이 'API 사용 설정됨' 이 활성화 되면 다시 프로젝트 설정의 클라우드 메시징 화면으로 돌아온다.
'Cloud Messaging API(기존)'을 다시 확인해보면 '사용 설정됨'으로 변경되고 서버 키가 생긴 것을 확인할 수 있다. 해당 서버 키를 복사하여 안드로이드 내에 선언 해주면 된다.
메시지를 서버로 전달하는 동작을 할 인터페이스를 생성해준다. 서버 키와 컨텐츠 타입을 헤더에 포함시켜 서버에 전달하며 API 인터페이스에 직접 '@Headers' 어노테이션을 붙인 파라미터를 추가한다.
📌 Retrofit HTTP Method
- GET : 서버에 정보를 조회할 때 사용되며 URL에 정보를 담아 요청한다.
- POST : 서버에 정보를 생성할 때 사용되며 body에 정보를 담아 요청한다.
- DELETE : 서버에 정보를 삭제할 때 사용된다.
- PUT : 서버에 정보를 수정할 때 사용되며 'POST'와 같이 body에 정보를 담아 요청한다.
interface NotificationAPI {
@Headers("Authorization: key=$SERVER_KEY", "content-type:$CONTENT_TYPE")
@POST("fcm/send")
suspend fun postNotification(@Body notification: PushNotification): Response<ResponseBody>
}
서버와 통신하는 부분을 만들어준다. 'Retrofit.Builder()'로 Retrofit 객체를 초기화 해주며 baseUrl은 'Repository.kt' 에서 선언한 API 서버의 기본 URL 주소를 넣어준다.
📌 .addConverterFactory((GsonConverterFactory.create()))
Json data를 사용자가 정의한 Java 객체로 변환해주는 라이브러리
class RetrofitInstance {
companion object {
private val retrofit by lazy {
Retrofit.Builder()
.baseUrl(BASE_URL)
.addConverterFactory((GsonConverterFactory.create()))
.build() // Retrofit 구현체 생성
}
// Retrofit 객체 생성
val api by lazy {
retrofit.create(NotificationAPI::class.java)
}
}
}
data class NotificationModel(
val title: String = "", // 알림 제목
val message: String = "" // 알림 텍스트
)
FirebaseMessagingService를 상속하는 MyFirebaseMessagingService 클래스를 생성한다.
class MyFirebaseMessagingService : FirebaseMessagingService() {
// 메시지를 수신할 때 호출 된다.
// 수신된 RemoteMessage 객체를 기준으로 작업을 수행하고 메시지 데이터를 가져올 수 있다.
override fun onMessageReceived(remoteMessage: RemoteMessage) {
super.onMessageReceived(remoteMessage)
// 메시지에 데이터 페이로드가 포함 되어 있는지 확인한다.
// 페이로드란 전송된 데이터를 의미한다.
if (remoteMessage.data.isNotEmpty()) {
Log.d(TAG, "Message data payload: ${remoteMessage.data}")
sendNotification(
remoteMessage.data["title"].toString(),
remoteMessage.data["message"].toString()
)
} else {
// 메시지에 알림 페이로드가 포함되어 있는지 확인한다.
remoteMessage.notification?.let {
sendNotification(
remoteMessage.notification!!.title.toString(),
remoteMessage.notification!!.body.toString()
)
}
}
}
// 새 토큰이 생성될 때마다 onNewToken 콜백이 호출된다.
// 등록 토큰이 처음 생성되므로 여기서 토큰을 검색할 수 있다.
override fun onNewToken(token: String) {
super.onNewToken(token)
sendRegistrationToServer(token)
}
// 메시지에 데이터 페이로드가 포함 되어 있을 때 실행되는 메서드
// 장시간 실행 (10초 이상) 작업의 경우 WorkManager를 사용하여 비동기 작업을 예약한다.
private fun scheduleJob() {
val work = OneTimeWorkRequest.Builder(MyWorker::class.java)
.build()
WorkManager.getInstance(this)
.beginWith(work)
.enqueue()
}
// 메시지에 데이터 페이로드가 포함 되어 있을 때 실행되는 메서드
// 10초 이내로 걸릴 때 메시지를 처리한다.
private fun handleNow() {
Log.d(TAG, "Short lived task is done.")
}
// 타사 서버에 토큰을 유지해주는 메서드이다.
private fun sendRegistrationToServer(token: String?) {
Log.d(TAG, "sendRegistrationTokenToServer($token)")
}
// 수신 된 FCM 메시지를 포함하는 간단한 알림을 만들고 표시한다.
private fun sendNotification(title: String, body: String) {
val intent = Intent(this, FCMActivity::class.java)
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
val pendingIntent = PendingIntent.getActivity(
this, 0, intent,
PendingIntent.FLAG_IMMUTABLE
)
val channelId = "fcm_default_channel"
val defaultSoundUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION)
val notificationBuilder = NotificationCompat.Builder(this, channelId)
.setSmallIcon(R.drawable.notification_icon)
.setContentTitle(title)
.setContentText(body)
.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())
}
companion object {
private const val TAG = "MyFirebaseMsgService"
}
internal class MyWorker(appContext: Context, workerParams: WorkerParameters) :
Worker(appContext, workerParams) {
override fun doWork(): Result {
return Result.success()
}
}
}
📌 액티비티에 현재 등록 된 토큰 가져오기
등록 된 토큰을 가져오려면 'FirebaseMessaging.getInstace().getToken()'을 호출한다.FirebaseMessaging.getInstance().token.addOnCompleteListener(OnCompleteListener { task -> if (!task.isSuccessful) { Log.w(TAG, "Fetching FCM registration token failed", task.exception) return@OnCompleteListener } // FCM 등록 토큰 가져오기 val token = task.result val msg = "FCM Registration token: " + token; Log.d(TAG, msg) Toast.makeText(baseContext, msg, Toast.LENGTH_SHORT).show() binding.textviewToken.text = msg })
우선 간단한 테스트로 PUSH 알림을 보내는 것을 확인해보고자 메시지를 받을 사용자의 token 값을 임의로 선언해놨다. EditText에 텍스트 값을 입력한 후 버튼을 누르면 임의로 선언한 token 값을 가지고 있는 사용자에게 메시지가 전송 되는 예제이다.
class FCMActivity : AppCompatActivity() {
companion object {
private val PERMISSION_REQUEST_CODE = 5000
private val TAG = "FCMActivity"
private var token = "TARGET_TOKEN" // 수신 받을 사용자의 토큰 값
}
private lateinit var binding: ActivityFcmBinding
private fun permissionCheck() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
val permissionCheck = ContextCompat.checkSelfPermission(
this,
android.Manifest.permission.POST_NOTIFICATIONS
)
if (permissionCheck != PackageManager.PERMISSION_GRANTED) {
ActivityCompat.requestPermissions(
this,
arrayOf(android.Manifest.permission.POST_NOTIFICATIONS),
PERMISSION_REQUEST_CODE
)
}
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = DataBindingUtil.setContentView(this, R.layout.activity_fcm)
permissionCheck()
// 현재 토큰을 가져오려면
// FirebaseMessaging.getInstace().getToken()을 호출한다.
FirebaseMessaging.getInstance().token.addOnCompleteListener(OnCompleteListener { task ->
if (!task.isSuccessful) {
Log.w(TAG, "Fetching FCM registration token failed", task.exception)
return@OnCompleteListener
}
// FCM 등록 토큰 가져오기
val token = task.result
val msg = "FCM Registration token: " + token;
Log.d(TAG, msg)
Toast.makeText(baseContext, msg, Toast.LENGTH_SHORT).show()
})
binding.btnSend.setOnClickListener {
val message = binding.messageArea.text.toString()
sendPush(message)
}
}
private fun sendNotification(notification: PushNotification) = CoroutineScope(Dispatchers.IO).launch {
try {
val response = RetrofitInstance.api.postNotification(notification)
if(response.isSuccessful) {
// Log.d(TAG, "Response: ${Gson().toJson(response)}")
} else {
Log.e(TAG, response.errorBody().toString())
}
} catch(e: Exception) {
Log.e(TAG, e.toString())
}
}
private fun sendPush(message: String) {
val PushNotification = PushNotification(
NotificationModel("채팅 알림", message),
token
)
sendNotification(PushNotification)
}
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<out String>,
grantResults: IntArray
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
when (requestCode) {
PERMISSION_REQUEST_CODE -> {
if (grantResults.isEmpty() || grantResults[0] != PackageManager.PERMISSION_GRANTED) {
Toast.makeText(applicationContext, "Permission is denied", Toast.LENGTH_SHORT)
.show()
} else {
Toast.makeText(applicationContext, "Permission is granted", Toast.LENGTH_SHORT)
.show()
}
}
}
}
}
<?xml version="1.0" encoding="utf-8"?>
<layout>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="horizontal"
tools:context=".fcm.FCMActivity">
<EditText
android:id="@+id/messageArea"
android:layout_width="0dp"
android:layout_height="70dp"
android:layout_margin="10dp"
android:layout_weight="0.7"
android:background="@drawable/background_radius"
android:hint="메시지 보내기"
android:padding="10dp" />
<Button
android:id="@+id/btnSend"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_margin="10dp"
android:background="@drawable/send_message" />
</LinearLayout>
</layout>
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<stroke
android:width="1dp"
android:color="#F2F2F2" />
<solid android:color="#F2F2F2" />
<corners android:radius="10dp" />
</shape>