Bibbidi Bobbidi Boo
article thumbnail

* TIL/개념: 최대한 공식 문서 & 책을 기반으로 배운 내용을 정리

* 현재 취준생으로 풋내기 개발자가 쓰는 글입니다.

* 그러니 조언과 지적 및 훈수는 언제나 환영입니다! 댓글로 많이 달아주세요!

 

개인 프로젝트에서 Notification을 적용하기 위해 찾아본 공식 문서 & 겪은 이슈를 정리


Android Notification

Notification이란?

사용자에게 알림을 줄 때 Android에서는 Notification 기능을 사용한다.

Notification은 단순 알림 기능을 넘어서서, 메시지 답변처럼 상호작용까지 가능하도록 지원한다.

그 밖에 지원하는 기능에 대해서는 여기 공식 문서를 참고할 것!

(요 포스팅에서는 기본적인 지식만 다룰 생각이다.)


Notification 구성

  1. 작은 아이콘 - 필수, setSmallIcon()
  2. 앱 이름 - 시스템에서 제공
  3. 타임 스탬프 - 시스템에서 제공하지만 setWhen()로 재정의하거나 혹은 setShowWhen(false)로 숨길 수 있음
  4. 큰 아이콘 - 선택 사항, setLargeIcon()으로 설정
  5. 제목 - 선택 사항, setContentTitle()으로 설정
  6. 텍스트 - 선택 사항, setContentText()로 설정

작은 아이콘만 필수로, 나머지는 선택 사항이다.


Notification Channel

Android 8.0부터는 모든 알림에 채널을 할당해야 한다.

하나의 앱은 여러 알림의 채널을 보유하고, 앱에서 보내는 알림의 각 유형에 별도의 채널을 할당할 수 있다.

사용자 인터페이스에서는 알림 채널을 카테고리라고 부른다.

Android 8.0 이상에서는 채널을 통해서 알림의 중요도를 지정할 수 있다.

  • 중요도란?
    • Android에서는 알림의 중요도를 사용해서 알림이 사용자의 시각적 혹은 청각적으로 방해하는 수준을 결정
    • 중요도가 높을수록 알림이 사용자를 방해하는 수준도 높아짐
    • 채널의 importance에 따라 결정되며, 사용자는 시스템 설정에서 알림 채널의 중요도를 변경할 수 있음.
    • 중요도 수준
      • 긴급: 알림음이 울리며 헤드업 알림으로 표시
      • 높음: 알림음이 울림
      • 중간: 알림음이 없음
      • 낮음: 알림음이 없고, 상태 표시줄에 표시되지 않음
    • 중요도와 상관없이 모든 알림은 사용자를 방해하지 않는 시스템 UI 위치에 표시된다.

Android 7.1 이하에서는 앱 하나당 하나의 채널을 가지게 되며,

알림의 중요도는 알림의 priority에 따라 결정짓게 된다.

 

그럼 채널은 언제 만들어야 할까?

Notification 을 띄워주기 전 채널이 등록되어야 하기 때문에, 그 전에 반드시 만들어주어야 한다.

아니면 알림 자체가 안 뜬다.

 

공식 문서에는 앱이 시작하자마자 이 코드를 실행해야 하며,

기존 채널을 또 만들게 되면 아무 작업도 실행되지 않기 때문에 반복적으로 호출하는 것이 안전하다고 알려준다.

 

+) 그래서 실제 프로젝트에 적용할 땐 아래처럼 Application 클래스에서 초기화 시켜주었다.

 

(밑에서 설명할) 알림 권한 등을 고려하면 채널을 알림 기능을 사용하기 직전에 만들어주어도 되지만,

생각해보면 위 앱화면에서 보이는 중간에 빠빰 하고 채널이 생기면 뭔가..이상할 것 같다는 생각..(알림 채널 자체가 앱의 정보라고 생각이 들어서..??)

사용했던 앱이 처음에 알림 채널을 만들고 허용을 묻는 것도 고려해서 위치는 여기에 두었다.

@HiltAndroidApp
class HabitTrackerApplication : Application() {
    override fun onCreate() {
        super.onCreate()
        // ...
        createNotificationChannels()
    }
    
    private fun createNotificationChannels() {
        // ...
    }
}

POST_NOTIFICATIONS

Android 13이상부터는 Notification 정책이 바뀌어서

 

런타임 권한에 POST_NOTIFICATIONS가 추가되고, 

알림 권한을 요청하는 시기를 앱에서 제어할 수 있게 되었다.

 

Android 13 이상에는 앱 설치 시에 알림이 기본적으로 꺼져 있기 때문에

앱에서 직접적으로 권한을 요청하지 않으면

사용자가 직접 앱 설정에 들어가서 알람 설정을 바꾸기 전까지 알림이 띄워지든 말든지 모른다.

 

때문에 사용자가 알림 기능을 사용하려고 할 때는 권한을 확인하고 메시지를 띄워주는 작업이 필요하다.

알람 받기 설정 시에 권한 요청하기

 

  • Android 13 이상에서는 직접 메시지를 만들어서 설명하고, 권한을 부여하도록 권장
  • Android 12 이하는 알림 채널을 만든 후 시작할 때 권한 대화상자를 표시한다.

구현

그럼 다음과 같은 요구사항이 있다고 가정하고, 구현 방법을 살펴보자.

  • 버튼을 클릭하면 Notification을 띄워준다.
  • 알림을 탭하면 FirstActivity에서 SecondActivity로 이동한다.

 

알림을 띄우기 전, 알림 채널을 만들어야 한다.

고유한 채널 ID, 사용자가 볼 수 있는 이름, 중요도 수준을 사용해 NotificationChannel 객체를 만들어서

createNotificatoinChannel()을 호출한다.

    private fun createNotificationChannel() {
        // Android 26 이상부터 채널 만들기
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            
            val name = "알람 테스트 제목"
            val descriptionText = "알람 테스트 설명"
            val importance = NotificationManager.IMPORTANCE_DEFAULT // 중요도 설정
            val channel = NotificationChannel(CHANNEL_ID, name, importance).apply {
                description = descriptionText
            }
            
            // NotificationManager 를 호출하여 채널 생성
            val notificationManager: NotificationManager =
                getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
            notificationManager.createNotificationChannel(channel)
        }
    }

 

 

POST_NOTIFICATIONS 권한을 AndroidMainfest.xml에 추가한다.

<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">

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

</manifest>

그리고 버튼을 클릭했을 때 권한을 확인한 후, showNotification()을 호출한다.

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityFirstBinding.inflate(layoutInflater)
        setContentView(binding.root)

        createNotificationChannel()

        binding.button.setOnClickListener {
            checkNotificationPermission()
        }
    }
    
    // 권한 요청 Launcher
    private val requestNotificationPermissionLauncher = registerForActivityResult(
        ActivityResultContracts.RequestPermission()
    ) { isGranted ->
        if (isGranted) {
            // 권한 허용 시
            showNotification()
        } else {
            // 거절 시
            showPermissionDenySnackBar()
        }
    }
    
    // 이미 권한을 허용한 경우인지 확인
    private fun isAlreadyGranted(permission: String) =
        ContextCompat.checkSelfPermission(
            this,
            permission
        ) == PackageManager.PERMISSION_GRANTED
    
    // 이전에 권한 요청을 거절한 경우인지 확인
    private fun isRationale(permission: String) =
        ActivityCompat.shouldShowRequestPermissionRationale(this, permission)
    
    // Notification 권한 확인
    private fun checkNotificationPermission() {
        when {
            // 이미 허용했으면 Notification 띄우기
            isAlreadyGranted(POST_NOTIFICATIONS) -> showNotification()
            // 이전에 이미 거절했으면 다이얼로그 띄우기
            isRationale(POST_NOTIFICATIONS) -> showRationaleAlertDialog()
            // 처음일 때
            else -> {
                // POST_NOTIFICATIONS 권한이 런타임 권한에 추가된 TIRAMISU 버전 이상이면 
                if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
                    // 권한 요청
                    requestNotificationPermissionLauncher.launch(POST_NOTIFICATIONS)
                } else {
                    // 다이얼로그 띄우기
                    showRationaleAlertDialog()
                }
            }
        }
    }
    
    // 권한이 왜 필요한지 설명 후 설정하기에서 권한 변경 권장
    private fun showRationaleAlertDialog() {
        MaterialAlertDialogBuilder(this)
            .setTitle("휴대폰 알림 설정이 꺼져 있어요")
            .setMessage("설정 페이지에서 먼저 알림 허용을 해주세요")
            .setNeutralButton("취소") { _, _ ->
            }.setPositiveButton("설정하기") { _, _ ->
                openAppSetting() // 설정하기로 이동
            }.show()
    }

    // 무시했을 경우 띄워줄 Snackbar
    private fun showPermissionDenySnackBar() {
        Snackbar.make(
            binding.root,
            "권한이 허용되어야 알림 설정이 가능해요!",
            Snackbar.LENGTH_SHORT
        ).show()
    }
    
    // 앱 설정 페이지로 이동
    private fun openAppSetting() {
        val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
            .setData(Uri.parse("package:${BuildConfig.APPLICATION_ID}"))
        startActivity(intent)
    }
   private fun showNotification() {
        /**
         * 1. Intent 설정: SecondActivity로 이동
         * */
        val intent = Intent(this, SecondActivity::class.java).apply {
            flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
        }
        // FLAG_ACTIVITY_NEW_TASK -> 앱의 기존 작업 및 백스택에 추가되는 대신 새 작업을 시작
        // FLAG_ACTIVITY_CLEAR_TASK -> 백스텍이 생성되어 뒤로 및 위로 버튼에 관한 사용자의 기대가 유지

        val pendingIntent: PendingIntent =
            PendingIntent.getActivity(this, 0, intent, PendingIntent.FLAG_IMMUTABLE)

        /**
         * 2. Notification 구성
         * */
        val builder = NotificationCompat.Builder(this, CHANNEL_ID)
            .setSmallIcon(R.drawable.ic_launcher_foreground)
            .setContentTitle("My notification")
            .setContentText("Hello World!") // 알림 구성 설정
            .setPriority(NotificationCompat.PRIORITY_DEFAULT) // 우선순위 설정
            .setContentIntent(pendingIntent) // Intent 설정(탭 시 해당 intent로 이동)
            .setAutoCancel(true) // 알림을 탭하면 자동으로 알림 삭제
            .setCategory(NotificationCompat.CATEGORY_MESSAGE)
        // 시스템 전체 카테고리 설정
        // -> 방해 금지 모드 설정했을 때 해당 Notification의 Category 설정
        // ex. CATEGORY_ALARM, CATEGORY_REMINDER, CATEGORY_EVENT 또는 CATEGORY_CALL

        /**
         * 3. Notification 보여주기
         * */
        val notificationId = "${System.currentTimeMillis()}".hashCode()
        with(NotificationManagerCompat.from(this)) {
            // notificationId is a unique int for each notification that you must define
            notify(notificationId, builder.build())
        }
    }
  1. Intent 설정
    • 알림을 탭했을 때 SecondActivity로 이동하기 위한 Intent를 설정한다.
    • 이 때는 Intent 뿐만 아니라 PendingIntent를 사용한다.
      • Intent는 SecondActivity에 이동하되, flag와 같은 메시지를 전달하는 역할 
      • PendingIntent는?
        • Intent처럼 어디에 전달되는 것은 맞다.
        • 공식 문서에는 다음처럼 나와있다.
          • 소유하는 애플리케이션의 프로세스가 종료되더라도 PendingIntent 자체는 제공된 다른 프로세스에서 계속 사용할 수 있습니다. 
          • 앱의 프로세스가 꺼져있는 상황에서 Notification을 탭해서 어딘가로 이동하려면 PendingIntent가 필요하다.
  2. Notification 구성
    • builder 패턴을 사용한다.
    • 맨 처음 context와 고유 채널ID를 넘긴다.
    • 그리고 setSmallcon, setContentTitle, setContentText를 이용해 아이콘, 제목, 본문 텍스트를 설정한다.
    • setPriority를 사용해서 우선순위를 설정한다.
      • 우선순위에 따라 Android 7.1 이하에서 얼마나 강제적이어야 하는지 설정된다.
      • Android 8.0 이상에서는 채널 중요도를 대신 설정한다.
    • setContentIntent를 사용해서 알림을 탭했을 때 반응을 설정한다.
    • setAutoCancel을 사용해서 알림을 탭하면 자동으로 알림이 삭제되도록 설정한다.
    • setCategory를 사용해서 시스템 전체 카테고리를 설정한다.
      • 방해금지 모드를 위한 것으로, 해당 Notification의 카테고리를 설정함으로서 방해금지모드일 때 Notification을 띄울지 말지 설정할 수 있다.
      • 더 자세한 사항은 공식 문서의 방해 금지 모드를 참고할 것.
  3. Notification 보여주기
    • notificationId를 전달하여 Notification을 띄운다.
    • 해당 id가 같은 경우, 한 번 더 notify()를 띄웠을 때 해당 알림이 업데이트된다.
    • 업데이트 외에도 id를 이용해서 cancel()을 호출해 삭제할 수 있다.

 

최종 코드.kt

class FirstActivity : AppCompatActivity() {

    private lateinit var binding: ActivityFirstBinding

    companion object {
        const val CHANNEL_ID = "CHANNEL_ID"
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityFirstBinding.inflate(layoutInflater)
        setContentView(binding.root)
		
        // Notification 만들기 전 채널 만들기
        // Application 등으로 이동 가능
        createNotificationChannel()
		
        // 버튼을 클릭하면 알림 권한 체크 
        binding.button.setOnClickListener {
            checkNotificationPermission()
        }
    }

    private fun createNotificationChannel() {
        // Android 26 이상부터 채널 만들기
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {

            val name = "알람 테스트 제목"
            val descriptionText = "알람 테스트 설명"
            val importance = NotificationManager.IMPORTANCE_DEFAULT // 중요도 설정
            val channel = NotificationChannel(CHANNEL_ID, name, importance).apply {
                description = descriptionText
            }

            // NotificationManager 를 호출하여 채널 생성
            val notificationManager: NotificationManager =
                getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
            notificationManager.createNotificationChannel(channel)
        }
    }

    private fun showNotification() {
        /**
         * 1. Intent 설정: SecondActivity로 이동
         * */
        val intent = Intent(this, SecondActivity::class.java).apply {
            flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
        }
        // FLAG_ACTIVITY_NEW_TASK -> 앱의 기존 작업 및 백스택에 추가되는 대신 새 작업을 시작
        // FLAG_ACTIVITY_CLEAR_TASK -> 백스텍이 생성되어 뒤로 및 위로 버튼에 관한 사용자의 기대가 유지

        val pendingIntent: PendingIntent =
            PendingIntent.getActivity(this, 0, intent, PendingIntent.FLAG_IMMUTABLE)

        /**
         * 2. Notification 구성
         * */
        val builder = NotificationCompat.Builder(this, CHANNEL_ID)
            .setSmallIcon(R.drawable.ic_launcher_foreground)
            .setContentTitle("My notification")
            .setContentText("Hello World!") // 알림 구성 설정
            .setPriority(NotificationCompat.PRIORITY_DEFAULT) // 우선순위 설정
            .setContentIntent(pendingIntent) // Intent 설정(탭 시 해당 intent로 이동)
            .setAutoCancel(true) // 알림을 탭하면 자동으로 알림 삭제
            .setCategory(NotificationCompat.CATEGORY_MESSAGE)
        // 시스템 전체 카테고리 설정
        // -> 방해 금지 모드 설정했을 때 해당 Notification의 Category 설정
        // ex. CATEGORY_ALARM, CATEGORY_REMINDER, CATEGORY_EVENT 또는 CATEGORY_CALL

        /**
         * 3. Notification 보여주기
         * */
        val notificationId = "${System.currentTimeMillis()}".hashCode()
        with(NotificationManagerCompat.from(this)) {
            // notificationId is a unique int for each notification that you must define
            notify(notificationId, builder.build())
        }
    }

    // 권한 요청 Launcher
    private val requestNotificationPermissionLauncher = registerForActivityResult(
        ActivityResultContracts.RequestPermission()
    ) { isGranted ->
        if (isGranted) {
            // 권한 허용 시
            showNotification()
        } else {
            // 거절 시
            showPermissionDenySnackBar()
        }
    }

    // 이미 권한을 허용한 경우인지 확인
    private fun isAlreadyGranted(permission: String) =
        ContextCompat.checkSelfPermission(
            this,
            permission
        ) == PackageManager.PERMISSION_GRANTED

    // 이전에 권한 요청을 거절한 경우인지 확인
    private fun isRationale(permission: String) =
        ActivityCompat.shouldShowRequestPermissionRationale(this, permission)

    // Notification 권한 확인
    private fun checkNotificationPermission() {
        when {
            // 이미 허용했으면 Notification 띄우기
            isAlreadyGranted(POST_NOTIFICATIONS) -> showNotification()
            // 이전에 이미 거절했으면 다이얼로그 띄우기
            isRationale(POST_NOTIFICATIONS) -> showRationaleAlertDialog()
            // 그 외에는 권한 요청
            else -> {
                // POST_NOTIFICATIONS 권한이 있는 TIRAMISU 버전 이상이면
                if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
                    // 권한 요청
                    requestNotificationPermissionLauncher.launch(POST_NOTIFICATIONS)
                } else {
                    // 다이얼로그 띄우기
                    showRationaleAlertDialog()
                }
            }
        }
    }

    // 권한이 왜 필요한지 설명 후 설정하기에서 권한 변경 권장
    private fun showRationaleAlertDialog() {
        MaterialAlertDialogBuilder(this)
            .setTitle("휴대폰 알림 설정이 꺼져 있어요")
            .setMessage("설정 페이지에서 먼저 알림 허용을 해주세요")
            .setNeutralButton("취소") { _, _ ->
            }.setPositiveButton("설정하기") { _, _ ->
                openAppSetting() // 설정하기로 이동
            }.show()
    }

    // 무시했을 경우 띄워줄 Snackbar
    private fun showPermissionDenySnackBar() {
        Snackbar.make(
            binding.root,
            "권한이 허용되어야 알림 설정이 가능해요!",
            Snackbar.LENGTH_SHORT
        ).show()
    }

    // 앱 설정 페이지로 이동
    private fun openAppSetting() {
        val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
            .setData(Uri.parse("package:${BuildConfig.APPLICATION_ID}"))
        startActivity(intent)
    }
}

 

이렇게 하면 다음처럼 동작한다.


PendingIntent

PendingIntent

PendingIntent에 대해서 위에서 앱의 프로세스가 종료되었을 때에도 전달할 수 있도록 동작한다고 서술했다.

다만 이슈도 있고, 헷갈리는 부분이 많아서 따로 서술..

 

PendingIntent는 알림을 탭했을 때 Activity를 실행할 때 뿐만 아니라 여러 곳에서 쓰이고,

getActivity(Context, int, Intent, int) 외에도 getActivities(Context, int, Intent [], int), getBroadcast(Context, int, Intent, int), getService(Context, int, Intent, int)가 있다.

 

 

첫 번째, 세 번째 파라미터인 Context, Intent는 예제가 있어서 거의 실수할 일 없이 넘어가지만,

보통 문제가 발생하는 지점은 두 번째, 네번째 파라미터에 해당하는 requestCode와 flags 값이다.

 

먼저 두 번째 파라미터인 int의 경우 requestCode를 의미하는데,

고정해도 될 때가 있고, 고유한 Code로 설정해야 할 때가 있다.

 

만약 위의 예제처럼

"Notifiaction을 탭하면 MainActivity로 이동한다."가 요구사항이라면

단순히 requestCode를 고정해도 상관은 없다.

 

그러나 Notification이 여러 개이고, Notification마다 MainActivity로 이동은 하되

putExtra 등으로 데이터를 주어서 약간은 다르게 보여주어야 할 때(즉 개별 동작이 필요할 때)

intent만 다르게 한다고 해서 되는 게 아니라 requestCode도 다르게 해주어야 한다.

 

그렇지 않으면 이동은 하는데 원하는 데이터가 나오지 않는 등 원하는 대로 동작하지 않는다.

 

실제로 아래처럼 putExtra를 주었는데도

requestCode를 0으로 두면

SecondActivity에서 getStringExtra로 뽑을 때 null이 나오더라..

        val intent = Intent(this, SecondActivity::class.java).apply {
            flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
            putExtra("KEY", LocalTime.now().toString())
        }

        val pendingIntent: PendingIntent =
            PendingIntent.getActivity(this, LocalTime.now().hashCode(), intent, PendingIntent.FLAG_IMMUTABLE)
        // requestCode를 고정 값으로 설정 시 getStringExtra에서 값이 들어오지 않는다.

 

다음으로 마지막 파라미터 int 값은 flags, 인데 

이전에 requestCode가 같은 PendingIntent가 있다면, 이를 취소하고 새롭게 할지, 변경할 지 등등을 설정하는 옵션이다.

  • FLAG_CANCEL_UPDATE: 기존 PendingIntent가 이미 존재하는 경우 취소하고 새롭게 생성한다.
  • FLAG_UPDATE_CURRENT: 기존 PendingIntent가 이미 존재하는 경우 이를 유지하되, 추가 데이터를 이 새 Intent에 있는 것으로 대체한다.
  • FLAG_ONE_SHOT: 이 PendingIntent를 한 번만 사용할 수 있다.
  • FLAG_NO_CREATE: 기존 PendingIntent가 존재하는 경우 회수한다.
  • FLAG_IMMUTABLE: 생성된 PendingINtent는 변경 불가능해야 한다.
  • FLAG_MUTABLE: 생성된 PendingIntent는 변경 가능해야 한다.

이슈: One of FLAG_IMMUTABLE or FLAG_MUTABLE be specified

flags는 FLAG_UPDATE_CURRENT로 하면 될 줄 .. 알았..으나..

java.lang.IllegalArgumentException: com.bibbidi.myapplication: Targeting S+ (version 31 and above) requires that one of FLAG_IMMUTABLE or FLAG_MUTABLE be specified when creating a PendingIntent.
Strongly consider using FLAG_IMMUTABLE, only use FLAG_MUTABLE if some functionality depends on the PendingIntent being mutable, e.g. if it needs to be used with inline replies or bubbles.

버전이 31 이상이면 PendingIntent를 생성할 때 flags에 FLAG_IMMUTABLE 혹은 FLAG_MUTABLE을 사용하라고 한다.

그러면서 FLAG_IMMUTABLE 사용을 강력히 권장한다고 함..

 

때문에 마지막 Flags를 FLAG_IMMUTABLE 혹은 FLAG_IMMUTABLE or FLAG_UPDATE_CURRENT로 해두면 에러는 뜨지 않는다.

그러나 버전 23부터 해당 Flags를 지원하기 때문에 경고줄까지 없애고 싶다면

minSdk를 23으로 바꾸거나 다른 조치를 취해야 하는 것으로 보인다.


 

profile

Bibbidi Bobbidi Boo

@비비디

포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!