Android

Android 알람 만들기 (feat. Service, Broadcast Receiver)

seokzoo 2022. 9. 15. 12:41
반응형

이번 포스트에서는 Broadcast Receiver, Service를 이용해서 알람을 만드는 방법을 알아봅니다!

먼저 알람에 대해서 알아볼텐데요, 안드로이드 developer 사이트에 올라온 set Alarm을 보면, 알람을 설정할 때 몇가지 기준이 필요하다고 합니다.

  1. 정확한 시간에 울려야 하는가? vs 정확한 시간이 아니여도 괜찮은가?

반드시 정확한 시간에 울려야하는 알람이 아니라면, setRepeating() 보다는 setInexactRepeating()을 이용합니다! 왜냐하면, 정확한 알람 설정은 부정확한 알람 설정보다 많은 배터리를 소모하기 때문입니다!

  1. 지정한 시간에 울려야 하는가? vs 기기를 부팅한 후의 시간을 통해 울려야 하는가?

알람의 유형은 4가지가 있습니다! ELAPSED_REALTIME, ELAPSED_REALTIME_WAKEUP, RTC, RTC_WAKEUP.

ELAPSED_REALTIME은 기기가 부팅된 후 경과된 시간을 통해 대기중인 인텐트를 실행합니다.
RTC는 실제 시간을 통해 대기중인 인텐트를 실행합니다.
(여기서 대기중인 인텐트는 매우 중요하므로 이따가 추가적으로 설명하겠습니다!)

또한 보시다 싶이 2개의 유형과 WAKE_UP이 붙은 2개의 추가적인 유형이 있는데, 이는 절전모드일 때 깨울지 말지를 선택하는 요소로, WAKE_UP이 붙어 있다면 절전모드를 깨우고 알람을 실행합니다!

알람 등록

구글의 예제 코드를 보겠습니다!

    private var alarmMgr: AlarmManager? = null
    private lateinit var alarmIntent: PendingIntent
    ...
    alarmMgr = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
    alarmIntent = Intent(context, AlarmReceiver::class.java).let { intent ->
        PendingIntent.getBroadcast(context, 0, intent, 0)
    }

    alarmMgr?.set(
            AlarmManager.ELAPSED_REALTIME_WAKEUP,
            SystemClock.elapsedRealtime() + 60 * 1000,
            alarmIntent
    )

위의 설명을 토대로 보면, 알람 매니저를 생성하고, 알람 매니저에 set() 함수를 통해 1분후 기기의 절전모드를 해제하고 일회성 알람을 실행합니다.

여기서 알수 있는 것은, set()함수는 1회성 알람을 설정, setRepeating()은 다회성 알람을 설정한다는 것,
ELAPSED_REALTIME_WAKEUP 을 이용해 절전모드를 깨우고, 기기가 부팅된 후 경과된 시간을 기반으로 대기중인 인텐트를 실행한다는 것입니다.

두 번째 예제 코드를 보겠습니다!

    val calendar: Calendar = Calendar.getInstance().apply {
        timeInMillis = System.currentTimeMillis()
        set(Calendar.HOUR_OF_DAY, 14)
    }

    // With setInexactRepeating(), you have to use one of the AlarmManager interval
    // constants--in this case, AlarmManager.INTERVAL_DAY.
    alarmMgr?.setInexactRepeating(
            AlarmManager.RTC_WAKEUP,
            calendar.timeInMillis,
            AlarmManager.INTERVAL_DAY,
            alarmIntent
    )

Calendar를 이용해 오후 2시의 시간을 설정, 알람 매니저를 통해 부정확한 반복의 알람을 설정했습니다. 이 알람은 대략 오후 2시쯤 절전모드를 해제하고, 현재 시간을 통해 알람을 울립니다.

알람 취소

쉽죠 ? 그럼 이번에는 알람을 설정했다면, 똑같이 취소하는 방법을 알아보겠습니다!

    // If the alarm has been set, cancel it.
    alarmMgr?.cancel(alarmIntent)

알람이 등록되어 있다면, 등록한 alarmIntent를 그대로 cancel() 메소드의 인자로 보내주면 바로 취소를 진행합니다.

취소는 어렵지 않죠?

안드로이드 재부팅 후 알람 재등록

그런데 중요한 점이 하나 있습니다! 안드로이드 OS는 기기를 재부팅하면 등록된 알람을 모두 취소합니다.

그래서 BootReceiver를 만들어 알람을 재등록해주는 과정이 필요합니다.

1. manifest 작성

먼저 매니페스트에서 해당 permission을 작성합니다.

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

2. BootReceiver 작성

후에 BootReceiver를 만들고, intent.action == "android.intent.action.BOOT_COMPLETED" 를 이용해 알람을 다시 설정해줍니다.

  class SampleBootReceiver : BroadcastReceiver() {

        override fun onReceive(context: Context, intent: Intent) {
            if (intent.action == "android.intent.action.BOOT_COMPLETED") {
                // Set the alarm here.
            }
        }
    }

3. manifest에 boot receiver 추가

<receiver android:name=".SampleBootReceiver"
            android:enabled="false">
        <intent-filter>
            <action android:name="android.intent.action.BOOT_COMPLETED"></action>
        </intent-filter>
    </receiver>

이렇게 3가지 작업을 해주면, 기기가 재부팅시에 BootReceiver에 intent.action이 실행됩니다.

저의 경우에는 알람의 정보를 Room에 저장하여 알람 정보를 꺼내 재등록해주었습니다.

@AndroidEntryPoint
class BootReceiver : BroadcastReceiver() {
    @Inject
    lateinit var local: LocalRemindDataSource

    override fun onReceive(context: Context, intent: Intent) {
        if (intent.action == Intent.ACTION_BOOT_COMPLETED) {
            val job = Job()
            CoroutineScope(job).launch {
                local.getAll()
                    .filter {
                        it.activate
                    }.forEach { reminder ->
                        val alarmManager = context.getSystemService(Activity.ALARM_SERVICE) as AlarmManager
                        val alarmIntent = Intent(context, AlarmReceiver::class.java).apply {
                            putExtra(StringUtils.ALARM_REMIND, reminder)
                        }
                        val pendingIntent =
                            PendingIntent.getBroadcast(
                                context,
                                reminder.id,
                                alarmIntent,
                                PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
                            )

                        val calendar = Calendar.getInstance().apply {
                            timeZone = TimeZone.getTimeZone("Asia/Seoul")
                            this[Calendar.HOUR_OF_DAY] = StringUtils.getTimeResult(reminder.time, hour = true)
                            this[Calendar.MINUTE] = StringUtils.getTimeResult(reminder.time, hour = false)
                            this[Calendar.SECOND] = 0
                            this[Calendar.MILLISECOND] = 0
                        }

                        val nowCalendar = Calendar.getInstance()
                        if (calendar.before(nowCalendar) || nowCalendar.time == calendar.time) {
                            calendar.add(Calendar.DATE, 1)
                        }

                        alarmManager.setExactAndAllowWhileIdle(
                            AlarmManager.RTC_WAKEUP,
                            calendar.timeInMillis,
                            pendingIntent
                        )
                    }
                job.cancel()
            }
        }
    }
}

코드를 설명해드리자면, Boot Receiver에서 재부팅 액션을 받아 들어오면, Room안에 저장된 알람 정보들 중 상태가 active하다면, 알람 매니저를 통해 알람을 재등록해줍니다.

여기서 중요한 점은, 알람을 등록할 때, TimeZone을 한국으로 설정해주어 기기가 재부팅되어 위치를 제대로 잡지 못해도 동일한 시간대로 알람을 지정한다는 점입니다. (가상 기기로 개발하다 보니, 재부팅시에 영국 시간대로 알람이 설정돼 알람이 설정 됐는데도 다른 시간대로 설정되어 안울렸었습니다 ㅠㅠ)

또한 setExactAndAllowWhileIdle() 메소드는 정확한 시간에 알람을 울리고, 기기가 잠자기 모드일때도 울리게 만들어 줍니다.

여기까지가 알람 구현의 기본이였습니다. 하지만 아직 알아보지 못한 Pending Intent가 있습니다.

Pending Intent

말 그대로 대기중인 Intent로, Intent를 나중에 실행하게끔 만든다고 생각하시면 편합니다! 즉, 알람이 울렸을 때 Intent를 실행한다고 생각하면 되는데, 위의 Pending Intent들로 예시를 들자면

    alarmMgr = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
    alarmIntent = Intent(context, AlarmReceiver::class.java).let { intent ->
        PendingIntent.getBroadcast(context, 0, intent, 0)
    }

알람 매니저를 통해 알람이 울릴 때, PendingIntent를 통해 AlarmReceiver를 실행하겠다! 라는 뜻입니다.

PendingIntent를 통해 BroadCast Recevier를 실행해주고 싶다면, getBroadCast를 사용하면 됩니다.

alarmIntent = Intent(context, AlarmService::class.java).let { intent ->
        PendingIntent.getService(context, 0, intent, 0)
    }

동일하게, PendingIntent를 통해 Service를 실행해주고 싶다면, getService를 사용하면 됩니다.

즉, 알람을 Background에서도 울리고 싶다면, 액티비티 -> 브로드 캐스트 리시버 -> 서비스의 순서로 알람을 순차적으로 보내면 됩니다.

그리고 이 포스트에서 가장 중요한 것!

API 31부터는 PendingIntent의 getBroadCast, getActivity 등의 마지막 인자에 flag를 설정해줄 때, PendingIntent.FLAG_IMMUTABLE 또는 FLAG_MUTABLE이 반드시 들어가야 합니다. 저는 이 flag 때문에 고생 많이 했는데요, 계속해서 PendingIntent를 통해 데이터를 전달해 줄 때 값이 최신화가 되지 않는 이슈로 고생을 정말 많이 했습니다...

그래서 찾은 해결책은 PendingIntent.getActivity(this, data.id, alarmIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) 과 같이 or을 통해 FLAG_UPDATE_CURRNET와 함께 써주면, Intent 정보가 최신화되어 전달됩니다. 그렇지 않으면, putExtra를 아무리 해도 이전 데이터만 받습니다.

각각의 FLAG에 대해서 알아보고, 그에 맞는 FLAG를 사용하는 것이 중요하다고 볼 수 있습니다. IMMUTABLE만 FLAG로 설정시에, 새로 들어온 Intent의 값들이 모두 무시되고 이전의 Intent Data를 이용하기 때문에 알람 값이 최신화되지 않을 수 있습니다.

아래의 블로그를 참고하시고 Pending Intent에 대해서 좀더 깊게 공부해보세요! flag 정보와 Pending Intent가 무엇인지 자세히 나와있습니다. All About PendingIntents

BroadCast Receiver

그렇다면 BroadCast Receiver에서 서비스로 알람을 보내는 코드를 보겠습니다.

class AlarmReceiver : BroadcastReceiver() {
    override fun onReceive(context: Context, intent: Intent) {
        val alarmIntent = Intent(context, AlarmService::class.java)
        alarmIntent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
        alarmIntent.putExtras(bundleOf(StringUtils.REMIND 
        to intent.extras?.getParcelable<ReminderData>(StringUtils.ALARM_REMIND)))

        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            context.startForegroundService(alarmIntent)
        } else {
            context.startService(alarmIntent)
        }
    }
}

Service와 Broadcast Receiver는 4대 컴포넌트이기 때문에 Intent로 데이터를 주고 받을 수 있습니다!

또한 버전에 따라 Service를 시작하는 코드가 다르니 분기 처리를 해주면 좋습니다! (오레오 이전과 이후로 나누면 됩니다.)

Service

그리고 시작된 서비스에서 Channel을 만들어 알림을 생성하면 됩니다. Oreo 이상에서는 반드시 Channel을 생성해서 Notification을 해야만 합니다. (여기서 팁은, Compat Class를 잘 활용해서 분기 처리를 신경써줍시다!)

여기서 몇가지 알면 좋은 점이 있는데, Service에서 Intent를 통해 Activity를 실행할 때, Intent에 flag를 줘야합니다.

val alarmIntent = Intent(this, AlarmActivity::class.java).apply {
            addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
            putExtras(bundleOf(StringUtils.ALARM_REMIND to data))
        }

그 이유는 Service는 백그라운드에서 항상 돌아가지만, Activity 객체는 메모리에 있을 수도 있고 없을 수도 있습니다.

즉, Service에서 Activity로 데이터를 전달하는데 Activity 객체가 메모리에 올라가 있지 않다면 새로 객체를 생성해주겠다는 이야깁니다.

registerAlarm, cancelAlarm

마지막으로 저의 알람 등록과 취소 코드를 보여드리고 마무리하겠습니다. 저는 등록과 취소의 sync를 맞추기 위해 PendingIntent의 id를 알람의 id와 동기화시켰습니다.

- registerAlarm -

 private fun registerAlarm(reminder: ReminderData) {
        val alarmManager = activity?.getSystemService(Activity.ALARM_SERVICE) as AlarmManager
        val intent = Intent(context, AlarmReceiver::class.java).apply {
            putExtra(StringUtils.ALARM_REMIND, reminder)
        }
        val pendingIntent =
            PendingIntent.getBroadcast(context, reminder.id, intent, PendingIntent.FLAG_UPDATE_CURRENT
            or PendingIntent.FLAG_IMMUTABLE)

        val calendar = Calendar.getInstance().apply {
            timeZone = TimeZone.getTimeZone("Asia/Seoul")
            this[Calendar.HOUR_OF_DAY] = StringUtils.getTimeResult(reminder.time, hour = true)
            this[Calendar.MINUTE] = StringUtils.getTimeResult(reminder.time, hour = false)
            this[Calendar.SECOND] = 0
            this[Calendar.MILLISECOND] = 0
        }

        val nowCalendar = Calendar.getInstance()
        if (calendar.before(nowCalendar) || nowCalendar.time == calendar.time) {
            calendar.add(Calendar.DATE, 1)
        }

        alarmManager.setExactAndAllowWhileIdle(
            AlarmManager.RTC_WAKEUP,
            calendar.timeInMillis,
            pendingIntent
        )
    }

Intent와 Pending Intent를 이용해 동작을 정의하고, 알람이 울릴 시간을 Calendar로 지정하고, 지정한 시간이 현재 시간과 동일하면 하루를 더해주고, 알람 매니저에 정확한 시간에, 잠자기 모드도 깨우는 알람을 RTC_WAKEUP(시간으로 알람 설정)을 통해 설정합니다.

- cancleAlarm-

private fun cancelAlarm(id: Int) {
        val alarmManager = activity?.getSystemService(Activity.ALARM_SERVICE) as AlarmManager
        val intent = Intent(context, AlarmReceiver::class.java)
        val pendingIntent = PendingIntent.getBroadcast(context, id, intent, PendingIntent.FLAG_IMMUTABLE)
        alarmManager.cancel(pendingIntent)
    }

Intent와 Pending Intent를 통해 등록할 때의 id를 통해그대로 취소해줍니다. 알람 등록과 취소를 할 때 각각의 알람을 구분하는 것은 2번째 인자, id이므로 id를 0,1 과 같이 막 지으면 안됩니다!

- delete -

private fun deleteReminder(reminder: ReminderData) {
        AlertDialog.Builder(context)
            .setMessage("삭제 하시겠습니까?")
            .setPositiveButton("예") { _, _ ->
                reminderViewModel.deleteRemind(reminder.id)
                cancelAlarm(reminder.id)
            }
            .setNegativeButton("아니오") { _, _ ->
            }
            .create()
            .show()
    }

알람 롱클릭하여 Room에서 알람 제거하고, 알람을 cancel해줍니다.

후기

저는 Pending Intent로 꽤나 삽질을 했습니다. API 31부터 변경되어 Flag를 MUTABLE 또는 IMMUTABLE을 반드시 사용해야했는데, 이 때문에 UPDATE를 적용하지 못해 이전의 Intent Data들이 계속해서 들어갔고, 알람의 최신화를 못하고 있었습니다. 다행히도 or을 이용해 두 가지 Flag를 적용해주면 최신화가 잘 되었지만, 알아내기까지 꽤나 힘들었습니다.

이번 알람에 대해 공부하면서 4대 컴포넌트중 3개를 직접적으로 사용해보기도 했고, Pending Intent가 무엇인지, 알람은 어떻게 설정해야 하는지에 대해서 알게됐습니다.

API 31 변경점이기 때문에 다른 블로그에서는 Pending Intent의 Flag에 대한 이야기가 없을 수도 있습니다.

저처럼 고생하지 말고 다른 분들도 도움을 받았으면 해서 포스팅하게 됐습니다! 감사합니다.

반응형