[Android] 알람 기능 구현하기(생성부터 취소, 기기 재부팅까지)

Minji Jeong·2022년 6월 8일
3

Android

목록 보기
21/39
post-thumbnail
이전에 Java로 간단한 일정관리 어플을 만들었었는데, 자잘한 버그들이 발견이 되어서 유지보수를 하던 중 코드의 심각성(스파게티..)을 너무 많이 느꼈다. 따라서 싹 갈아엎고 Kotlin으로 다시 개발하고 있는 중인데, 알람 기능을 만들면서 많이 헤맸기 때문에 공부도 다시 할겸 소스코드도 공유할겸 이렇게 포스팅을 남기게 되었다.

먼저 내가 만들고자 하는 알람 기능은 다음과 같다.
1. 타임피커로 알람 시간을 설정할 수 있다.
2. 설정된 시간에 Notification(앱의 UI 외부에 표시하는 메세지)으로 알람을 받아볼 수 있다.
3. 알람을 취소할 수 있다.
4. 기기 재부팅 시에도 알람이 그대로 유지되어야 한다(기기가 종료되면 모든 알람이 취소되기 때문).

알람 기능을 구현하기 위해 생성한 4개의 클래스는 다음과 같다.
AlarmFunctions : 알람 생성, 취소를 담당한다.
AlarmReceiver : 정해진 시간에 AlarmManager로부터 호출을 받는다.
AlarmService : 백그라운드에서 알람을 실행시킨다.
RebootAlarmReceiver : 기기 재부팅 시 취소되었던 알람들을 재설정한다.

1. AlarmReceiver

알람 시간이 되었을 때 동작할 기능을 Receiver의 onReceive()에 정의한다. 오레오 버전(API 레벨 26) 이상은 반드시 채널을 설정해줘야 알림이 작동하며, NotificationChannel 인스턴스를 createNotificationChannel()에 전달하여 앱 알림 채널을 시스템에 등록해야 한다.

또한 리시버를 호출한 외부 클래스로부터 전달받은 알람 요청코드와 알람 내용을 펜딩 인텐트의 생성 파라미터로 넣는다. API 레벨 31부터는 펜딩 인텐트 생성 시 FLAG 변수로 FLAG_IMMUTABLE을 사용해야 하기 때문에 조건문을 작성해서 경우에 따라 다르게 펜딩 인텐트를 생성하도록 해주었다.

💡 Pending Intent

외부 애플리케이션에 권한을 허가해서 전달하는 인텐트로, 인텐트를 감싸서 다른 애플리케이션에 전달할 때 바로 인텐트의 작업을 실행시키는 것이 아니라 특정 시점에 작업을 실행한다. 보통 Notification으로 알림을 만들 때나 AlarmManager를 통해 지정된 시간에 Intent가 시작되도록 할 때 사용한다.

FLAG_CANCEL_CURRENT : 이전에 생성한 PendingIntent 는 취소하고 새로 만든다.
FLAG_NO_CREATE : 이미 생성된 PendingIntent 가 없다면 null을 반환하고, 생성된 PendingIntent 가 있다면 해당 intent 반환한다.
FLAG_ONE_SHOT : 한번만 사용한다.
FLAG_UPDATE_CURRENT : 이미 생성된 PendingIntent 가 존재하면 해당 intent 의 extra data 만 변경한다.

class AlarmReceiver() : BroadcastReceiver() {

    private lateinit var manager: NotificationManager
    private lateinit var builder: NotificationCompat.Builder

    //오레오 이상은 반드시 채널을 설정해줘야 Notification 작동함
    companion object{
        const val CHANNEL_ID = "channel"
        const val CHANNEL_NAME = "channel1"
    }

    @SuppressLint("UnspecifiedImmutableFlag")
    override fun onReceive(context: Context?, intent: Intent?) {
        manager = context?.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager

        //NotificationChannel 인스턴스를 createNotificationChannel()에 전달하여 앱 알림 채널을 시스템에 등록
        manager.createNotificationChannel(
            NotificationChannel(
                CHANNEL_ID,
                CHANNEL_NAME,
                NotificationManager.IMPORTANCE_DEFAULT
            )
        )

        builder = NotificationCompat.Builder(context, CHANNEL_ID)

        val intent2 = Intent(context, AlarmService::class.java)
        val requestCode = intent?.extras!!.getInt("alarm_rqCode")
        val title = intent.extras!!.getString("content")

        val pendingIntent = if (Build.VERSION.SDK_INT>=Build.VERSION_CODES.S){
            PendingIntent.getActivity(context,requestCode,intent2,PendingIntent.FLAG_IMMUTABLE); //Activity를 시작하는 인텐트 생성
        }else {
            PendingIntent.getActivity(context,requestCode,intent2,PendingIntent.FLAG_UPDATE_CURRENT);
        }

        val notification = builder.setContentTitle(title)
            .setContentText("SCHEDULE MANAGER")
            .setSmallIcon(R.drawable.btn_star)
            .setAutoCancel(true)
            .setContentIntent(pendingIntent)
            .build()

        manager.notify(1, notification)
    }
}

알림을 생성하기 위해선 NotificationCompat.Builder를 사용해 알림 컨텐츠와 채널을 설정해야 하며, 알림을 표시하기 위해 NotificationManager의 notify()를 호출해 알림 ID와 NotificationCompat.Builder.build()의 결과를 전달한다.
setContentTitle() : 알림 제목을 설정한다.
setContentText() : 알림의 본문을 설정한다.
setSmallIcon() : 알림의 아이콘을 설정한다.
setAutoCancel() : true로 설정할 시 알림을 탭하면 삭제할 수 있다.
setContentIntent() : 알림을 눌렀을 때 실행할 작업 인텐트를 설정한다.

생성한 리시버는 꼭 Manifest.xml에 등록해주자. 참고로 API 레벨 31부터는 정확한 시간에 알람을 울리기 위해 manifest.xml에 SCHEDULE_EXACT_ALARM 퍼미션을 선언해줘야 한다.

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tool">

    <uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
      
    <application
		...>
        <receiver
            android:name=".AlarmReceiver"
            android:enabled="true"
            android:exported="false">
        </receiver>
        <activity
            android:name=".MainActivity"
            android:exported="true">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />

                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
    </application>

</manifest>

2. AlarmFunctions

액티비티나 프래그먼트에서 AlarmFunctions의 callAlarm을 호출해 알람을 설정할 수 있도록 했고, cancelAlarm을 호출해 알람을 취소할 수 있도록 했다. 알람 시간은 2000-00-00 HH:MM:SS 형태로 설정하고(시,분은 타임피커에서 설정한 시간을 가져왔는데, 정시에 울릴 것이기 때문에 초는 00으로 지정했다), 알람을 생성하고 취소하기 위한 고유의 요청 코드를 생성한다. 나는 Random 함수를 사용해 1~100000범위에서 생성된 랜덤한 정수를 요청 코드로 사용했는데, 사실 이 경우 범위를 크게 해도 아주 낮은 확률로 중복된 요청코드가 생성될 수 있기 때문에, 급하지 않다면 다른 방법을 사용해서 생성하는게 더 좋다(예를 들어 2022년 6월 1일 18시 30분에 울려야 하는 알람이라면 22611830 이런식으로 다른 알람들과 겹치지 않게).

hour = binding.timePicker.hour.toString()
minute = binding.timePicker.minute.toString()
time = "2000-00-00 $hour:$minute:00" // 알람이 울리는 시간

val random = (1..100000) // 1~100000 범위에서 알람코드 랜덤으로 생성
alarmCode = random.random()
setAlarm(alarmCode, content, time)

AlarmFunctions.callAlarm()

알람을 생성하기 위해 액티비티에서 설정한 알람 시간, 요청 코드, 알람 내용을 AlarmmFunctions.callAlarm()에 전달한다.

private fun setAlarm(alarmCode : Int, content : String, time : String){
	alarmFunctions.callAlarm(time, alarmCode, content)
}

callAlarm() 내에선 알람 요청코드와 내용을 AlarmReceiver에 전달한다. 알람 시간은 Date 객체로 변환해서 Calendar 객체의 time으로 설정하고, Calendar 객체를 AlarmManager.setExactAndAllowWhileIdle()의 전달인자로 보낸다.

💡 AlarmManager

AlarmManager는 알람 서비스에 대한 접근 권한을 제공하며, 애플리케이션이 미래의 어느 시점에 작동되도록 스케쥴링 하는 것을 허용한다. AlarmManager를 사용해서 알람을 울리는 데 사용되는 method는 여러가지가 있는데, 마시멜로 버전(API 레벨 23)부터 Doze mode가 도입되면서 기존에 사용하던 setExact, set을 사용했을 경우 Doze mode에 진입하면 알람이 울리지 않기 때문에 setExactAndAllowWhileIdle을 사용해야 한다.

setExactAndAllowWhileIdle(int type, long triggerAtMillis, PendingIntent operation)

  • triggerAtMills : 타입에 따른 시간

    • RTC,RTC_WAKEUP : 시간을 밀리세컨트 단위로 바꾼 값을 넣어준다.캘린더 객체 또는 System 객체의 currentTimeMillis 메서드를 사용해 현재 시간을 밀리 세컨드로 반환받아, 원하는 시간을 더한 값을 입력하면 더한 시간 뒤에 알람이 울린다.
    • ELAPSED_REALTIME,ELAPSED_REALTIME_WAKEUP : 부팅된 이후의 시간 값으로 밀리 세컨드 단위다. SystemClock 객체의 elapsedRealTime 메서드를 사용해 부팅 이후 경과된 시간을 반환 받고 해당 값에 원하는 시간을 더한 값을 입력하면 더한 시간 뒤에 알림이 울린다.
  • 🙄 Doze mode

    • 안드로이드 6.0 부터 추가된 정책으로, 기기가 전원에 연결되어 있지 않거나 오랫동안 사용하지 않는 경우 앱의 백그라운드 CPU 및 네트워크 활동을 지연시켜 배터리 소모를 줄이는 배터리 절약 정책이다.

class AlarmFunctions(private val context: Context){

    private lateinit var pendingIntent: PendingIntent
    private val ioScope by lazy { CoroutineScope(Dispatchers.IO) }

    fun callAlarm(time : String, alarm_code : Int, content : String){
    
        val alarmManager = context?.getSystemService(Context.ALARM_SERVICE) as AlarmManager
        val receiverIntent = Intent(context, AlarmReceiver::class.java) //리시버로 전달될 인텐트 설정
        receiverIntent.apply {
            putExtra("alarm_rqCode", alarm_code) //요청 코드를 리시버에 전달
            putExtra("content", content) //수정_일정 제목을 리시버에 전달
        }

        val pendingIntent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S){
            PendingIntent.getBroadcast(context,alarm_code,receiverIntent,PendingIntent.FLAG_IMMUTABLE)
        }else{
            PendingIntent.getBroadcast(context,alarm_code,receiverIntent,PendingIntent.FLAG_UPDATE_CURRENT)
        }

        val dateFormat = SimpleDateFormat("yyyy-MM-dd H:mm:ss")
        var datetime = Date()
        try {
            datetime = dateFormat.parse(time) as Date
        } catch (e: ParseException) {
            e.printStackTrace()
        }

        val calendar = Calendar.getInstance()
        calendar.time = datetime

        //API 23(android 6.0) 이상(해당 api 레벨부터 도즈모드 도입으로 setExact 사용 시 알람이 울리지 않음)
        alarmManager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP,calendar.timeInMillis,pendingIntent);
    }

    fun cancelAlarm(viewModel: ViewModel, alarm_code: Int) {
        ...
    }
}

AlarmFunctions.cancelAlarm()

알람을 취소하기 위해서 AlarmManager.cancel()에 펜딩 인텐트를 전달한다. 이 때 알람 요청코드는 알람을 생성했을 때 사용했던 요청 코드와 동일해야 한다.

class AlarmFunctions(private val context: Context){

    private lateinit var pendingIntent: PendingIntent
    private val ioScope by lazy { CoroutineScope(Dispatchers.IO) }

    fun callAlarm(time : String, alarm_code : Int, content : String){
        ...
    }

    fun cancelAlarm(alarm_code: Int) {
        val alarmManager = context?.getSystemService(Context.ALARM_SERVICE) as AlarmManager
        val intent = Intent(context, AlarmReceiver::class.java)

        pendingIntent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S){
            PendingIntent.getBroadcast(context,alarm_code,intent,PendingIntent.FLAG_IMMUTABLE)
        }else{
            PendingIntent.getBroadcast(context,alarm_code,intent,PendingIntent.FLAG_UPDATE_CURRENT)
        }

        alarmManager.cancel(pendingIntent)
    }
}

3. AlarmService

AlarmReceiver가 호출할 Service 클래스를 만든다.

class AlarmService: Service() {

    override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int {
        return START_STICKY
    }

    override fun onBind(p0: Intent?): IBinder? {
        throw UnsupportedOperationException("Not yet implemented");
    }
}

✅ How to set an alarm on reboot?

알람 기능을 처음 구현할 때 제일 헤맸던 부분이다. 앞서 언급했지만, AlarmManager를 통해 등록된 알람들은 기기가 꺼지거나 재부팅되면 모두 지워진다. 따라서 이러한 상황이 발생할 경우 아직 울리지 않은 알람들을 다시 등록해줘야 하는데, 나는 등록된 알람들을 Room 라이브러리를 사용해 로컬 데이터베이스에 저장한 뒤, 알람이 울리면 해당 알람을 데이터베이스에서 삭제하고, 아직 울리지 않은(데이터베이스에 남아있는) 알람들을 재부팅 시에 재등록 해주었다.

먼저 알람을 저장하기 위한 테이블과 인터페이스들을 만들고, 재부팅 시에 알람을 재등록해 주기 위한 리시버 클래스를 만들자.


@Entity(tableName = "active_alarms") // 재부팅 시 활성화 되어야하는 알람 테이블
data class AlarmDataModel(
    @PrimaryKey(autoGenerate = true)
    var serialNum: Int, // 일련 번호
    var alarm_code : Int, // 알람 요청코드
    var time : String, // 시간
    var content : String // 알람 내용
)
@Dao
interface AlarmDao { // 재부팅 시 관리되어야 하는 알람 저장용 테이블 관련
    @Query("select * from active_alarms")
    fun getAllAlarms() : List<AlarmDataModel>

    @Insert(onConflict = OnConflictStrategy.REPLACE) // 알람은 중복되지 않게 저장
    fun addAlarm(item: AlarmDataModel)

    @Query("DELETE FROM active_alarms WHERE alarm_code = :alarm_code") // 알람 코드로 삭제
    fun deleteAlarm(alarm_code: Int)
}
class RestartAlarmReceiver : BroadcastReceiver() {

    override fun onReceive(context: Context, intent: Intent) {
    ...
    }
}

재부팅 시 알람이 울리도록 하기 위해선 Manifest.xml에 몇가지 작업을 해줘야한다. Manifest.xml 다음의 퍼미션을 추가한 후 방금 만들었던 리시버 클래스를 등록해주자.

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

    <application
       ...>
        <receiver
            android:name=".AlarmReceiver"
            android:enabled="true"
            android:exported="false">
        </receiver>
        <receiver
            android:name=".RestartAlarmReceiver"
            android:enabled="true"
            android:exported="false" >
        </receiver>
        <activity
            android:name=".MainActivity"
            android:exported="true">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
    </application>

등록한 리시버 내부에 ACTION_BOOT_COMPLETED 작업을 필터링하는 인텐트 필터를 추가한다. android_permission.RECEIVE_BOOT_COMPLETED 퍼미션을 추가했기 때문에 재부팅 시 브로드캐스트되는 Intent.BOOT_COMPLETED를 애플리케이션이 수신할 수 있다.

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

이제 리시버의 onReceive() 내부에서 알람을 재설정하기 위한 코드를 작성해야 한다. onReceive() 내에서 리시버에 등록해줬던 인텐트 필터를 받아올 수 있는데, 전달된 값이 android.intent.action.BOOT_COMPLETED일 때만 동작하도록 조건문을 작성해준다. 나는 ViewModel + Koin + Room 을 사용해서 로컬 DB 작업을 해줬는데, 일반 클래스에서 Koin으로 의존성을 주입하려면 주입해야하는 모듈 자체를 파라미터로 선언하고 액티비티 또는 프래그먼트로부터 주입받은 뷰모델을 사용해야 하는게 일반적이나, 리시버의 생성자는 비어있는게 원칙이고(empty constructor), 따라서 Koin으로 ViewModel을 주입받아서 사용할 수 있는 상황이 아니였다. 온갖 방법을 시도해본 결과🤔 코루틴 스코프 내부에서 데이터베이스 인스턴스를 가져와서 사용하기로 했다. db로부터 재설정해야 하는 모든 알람들을 가져와 AlarmFunctions.callAlarm()으로 재등록해주면 끝이다. 이제 기기 재부팅 시에도 기존에 설정했었던 알람들을 받을 수 있다!

class RestartAlarmReceiver : BroadcastReceiver() {

    private val coroutineScope by lazy { CoroutineScope(Dispatchers.IO) }
    private lateinit var functions: AlarmFunctions

    override fun onReceive(context: Context, intent: Intent) {
        if (intent.action.equals("android.intent.action.BOOT_COMPLETED")) {
            functions = AlarmFunctions(context)
            coroutineScope.launch {
                val db = AppDatabase.getInstance(context)
                val list = db!!.alarmDao.getAllAlarms()
                val size = db.alarmDao.getAllAlarms().size
                list.let {
                    for (i in 0 until size){
                        val time = list[i].time
                        val code = list[i].alarm_code
                        val content = list[i].content
                        functions.callAlarm(time, code, content) // 알람 실행
                    }
                }
            }
        }
    }
}

References

https://codechacha.com/ko/android-alarmmanager/
https://developer88.tistory.com/83
https://developer.android.com/training/notify-user/build-notification?hl=ko

profile
Mobile Software Engineer

0개의 댓글