[Android / Kotlin] Firebase Cloud Messaging - PUSH 알림 보내기 (feat. Retrofit)

Subeen·2023년 4월 4일
0

Android

목록 보기
18/73
post-thumbnail

이전 FCM - PUSH 알림 받기 포스팅에서는 FCM을 사용하여 Firebase Console에서 메시지를 전송하고 PUSH 알림을 받는 예제를 만들었다.
본 포스팅에서는 안드로이드 앱에서 FCM을 사용하여 다른 사용자에게 메시지를 전송하는 예제를 만들어 보려고 한다.
Firebase 프로젝트 생성 및 연결 부분은 참고할 수 있는 다른 예제들도 많이 있기에 생략한다.

  • 이번 예제를 요약하면 다음과 같다.
    • 안드로이드 앱에서 Firebase 서버로 메시지를 전송한다.
    • Firebase 서버에서 앱으로 메시지를 전송한다.
    • 앱에서 메시지를 수신하고 수신 한 메시지를 알림으로 띄워준다.
    • 서버 통신은 Retrofit을 사용한다.

📍 Android 라이브러리 종속 항목 추가

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를 확장하는 서비스 추가
이 서비스는 백그라운드에서 앱의 알림을 수신하는 것 외에 다른 방식으로 메시지를 처리하려는 경우에 필요하다. 포그라운드 앱의 알림 수신, 데이터 페이로드 수신, 업스트림 메시지 전송 등을 수행하려면 이 서비스를 확장해야 한다.

👩🏻‍💻 AndroidManifest.xml

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

📌 Android 13(API 수준 33)에서는 앱에서 예외 없는 알림을 보내기 위한 새로운 런타임 권한 POST_NOTIFICATIONS를 도입했다.

👩🏻‍💻 AndroidManifest.xml

앱의 매니페스트 파일에서 POST_NOTIFICATIONS 권한을 선언한다.

<manifest ...>
    <uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
    <application ...>
        ...
    </application>
</manifest>

👩🏻‍💻 FCMActivity.kt

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

👩🏻‍💻 Repository.kt

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"
    }
}

💡 SERVER_KEY 가져오기

👉🏻 파이어베이스 콘솔에서 프로젝트로 진입한 후 좌측 탭의 '프로젝트 개요 설정 > 프로젝트 설정' 을 클릭한다.

👉🏻 프로젝트 설정 화면에서 클라우드 메시징 탭을 클릭한다. 화면 하단의 'Cloud Messaging API(기존)'의 우측에 있는 메뉴 아이콘을 클릭하면 다음 화면과 같이 'Google Cloud Console에서 API 관리' 탭이 나타난다.

👉🏻 'Google Cloud Console에서 API 관리'를 클릭하면 다음과 같은 화면으로 이동하며 사용 버튼을 누른다.

👉🏻 사용 버튼을 누르고 위와 같이 'API 사용 설정됨' 이 활성화 되면 다시 프로젝트 설정의 클라우드 메시징 화면으로 돌아온다.
'Cloud Messaging API(기존)'을 다시 확인해보면 '사용 설정됨'으로 변경되고 서버 키가 생긴 것을 확인할 수 있다. 해당 서버 키를 복사하여 안드로이드 내에 선언 해주면 된다.

📍 Interface 생성하기

메시지를 서버로 전달하는 동작을 할 인터페이스를 생성해준다. 서버 키와 컨텐츠 타입을 헤더에 포함시켜 서버에 전달하며 API 인터페이스에 직접 '@Headers' 어노테이션을 붙인 파라미터를 추가한다.

👩🏻‍💻 NotificationAPI.kt

📌 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 Instance 생성하기

서버와 통신하는 부분을 만들어준다. 'Retrofit.Builder()'로 Retrofit 객체를 초기화 해주며 baseUrl은 'Repository.kt' 에서 선언한 API 서버의 기본 URL 주소를 넣어준다.

📌 .addConverterFactory((GsonConverterFactory.create()))
Json data를 사용자가 정의한 Java 객체로 변환해주는 라이브러리

👩🏻‍💻 RetrofitInstance.kt

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)
        }
    }
}

📍 데이터 모델 생성하기

👩🏻‍💻 NotificationModel.kt

data class NotificationModel(
    val title: String = "", // 알림 제목
    val message: String = "" // 알림 텍스트
)

📍 FirebaseMessagingService

FirebaseMessagingService를 상속하는 MyFirebaseMessagingService 클래스를 생성한다.

👩🏻‍💻 MyFirebaseMessagingService.kt

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()
        }
    }
}

📍 Activity

📌 액티비티에 현재 등록 된 토큰 가져오기
등록 된 토큰을 가져오려면 '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
})

👩🏻‍💻 FCMActivity.kt

우선 간단한 테스트로 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()
                }
            }
        }
    }
}

👩🏻‍💻 activity_fcm.xml

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

👩🏻‍💻 background_radius.xml

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

HTTP 프로토콜
Retrofit
코드 참조

profile
개발 공부 기록 🌱

0개의 댓글