Pa K'ode

[안드로이드] Alarm Manager 를 활용한 알람기능 구현 본문

안드로이드

[안드로이드] Alarm Manager 를 활용한 알람기능 구현

Paku 2023. 10. 5. 19:25

안녕하세요, 안드로이드 개발자 파쿠 입니다 ;)

이번 포스팅에선 알람 기능 구현에 초점을 두어 기술하려고 합니다.

 

알람 매니저에 대해 기술한 블로그를 찾아보다 sdk29 이전의 관련한 내용이 많아,

최신버전을 타겟팅하기에는 변경된 부분이 많기도 해서

해당 주제로 포스팅을 하게되었습니다!

 

 

알람 기능을 구현하기 위해선 아래 세가지 기능만 구현하면 되는데요,

 

  • 알람을 예약할 AlarmManager
  • 예약된 이벤트를 전달받을 Receiver
  • 이벤트를 실행할 Service

알람기능은 기본적으로 백그라운드에서 실행되기에, 

안드로이드 버전별로 구현하야할 조건이 버전별로 변경될수 있습니다.

해당 포스팅에선 아래 스팩으로 구현 한 점, 참고부탁드립니다.

 

minSdk: 26

targetSdk: 33

 

위 조건 반대 순서별로 하나씩 알아보도록 하겠습니다.

그 전에 알람에 정보를 담고있을 Alarm 객채를 생성 해야하는데요,

 

  • 고유 식별자 (int형),
  • 시간(0-23),
  • 분(0-59),
  • 반복 요일

의 최소 네가지 정보만 담고 있으면 됩니다.

이 외의 알람에 구현하고싶으신 기능이 있다면 추가해 주신 후

Serializable 인터페이스를 상속받아 직렬화 시켜주도록 합니다.

 

시간, 분 설정을 구현 할 UI 작업은 해당 포스팅에선 스킵하도록 하겠습니다.

 

코드상에 존재하는 AlarmUtil 관련 항목은 AlarmManager쪽에 구현되어있습니다.

AlarmService

Service 컴포넌트를 상속받는 클래스를 생성해줍니다.

안드로이드 ? 이상 부터는

백그라운드에서 실행되는 서비스라면 사용자에게 알람을 띄워줘야합니다.

onStartCommand 함수를 override 하여 작성 해 줍니다.

override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
    return when (intent?.action) {
        ACTION_START -> {
            startAlarm(AlarmUtil.getAlarmFromIntent(intent))
            START_NOT_STICKY
        }

        ACTION_STOP -> {
            stopService(intent)
            STOP_FOREGROUND_DETACH
        }

        else -> super.onStartCommand(intent, flags, startId)
    }
}

Intent로 부터 알람 객채를 전달받은 후 미리 추가 해 둔 액션별로 기능을 분기 해 두었습니다.

 

알람을 실행할 함수 내에 작성 (startAlarm)
private val notificationManager: NotificationManager = getSystemService()!!

private fun createNotificationChannel() {
    val channel = NotificationChannel(
        getString(R.string.default_channel_id), // 고유 채널 아이디
        getString(R.string.default_channel_name), // 고유 채널 이름
        NotificationManager.IMPORTANCE_HIGH 
    ).apply {
        description = getString(R.string.default_channel_description) // 고유 채널 설명
        setSound(null, null)
    }

    notificationManager.createNotificationChannel(channel)
}

채널을 생성할떄 주의할 점은 importance(중요도)는 IMPORTANCE_HIGH로 설정되어있어야

기능들이 제약없이 동작할수 있습니다,

또한 이로인해 알림이 생성될떄 알림음이 발생하게되는데 

setSound(null, null) 로 설정하게 되면, 더이상 알림음이 발생하지 않습니다. :)

 

Notification 에는 해당 알람에 관련한 정보와, 기타 액션들에대한 정보를 추가해줍니다.

val notification = NotificationCompat.Builder(
    this,
    getString(R.string.default_channel_id), // 생성한 채널의 아이디 값
).apply {
    setContentTitle(getString(R.string.default_channel_name)) // 채널과 동일할 필요 없음
    setContentText(alarm.name)
    setSmallIcon(R.mipmap.ic_launcher)
    setCategory(NotificationCompat.CATEGORY_ALARM)
    setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
    priority = NotificationCompat.PRIORITY_MAX
    setContentIntent(알림 클릭시 실행 할 PendingIntent)
    setFullScreenIntent(알림 실행 시 실행 할 PendingIntent, true)

    if (alarm.isSnoozed) {
       addAction(0, "다시 알람", 스누즈 관련 PendingIntent)
    }
    addAction(0, "취소", 서비스를 중지 할 PendingIntent)
}
  • setCategory
    • 알림의 카테고리 설정
  • setVisibility 
    • 잠금화면 등에서 표시할지의 정보
  • priority 
    • 알림의 우선도(중요도)
    • 채널설정과 마찬가지로 Max로 설정
  • setContentIntent
    • 알림 클릭 시 실행할 PendingIntent
  • setFullScreenIntent
    • 기기가 수면모드로 전환되었을떄 실행할 PendingIntent 
    • 알람 울릴떄 보는 화면
  • addAction
    • 알림에서 처리할수 있는 기능
    • 제목과 pendingIntent로 기능 추가 가능

노티까지 생성하셨다면, startForeground 함수로 포그라운드 서비스를 실행 시켜줍니다.

startForeground(
   System.currentTimeMillis().toInt(),
   notification.build()
)

다음은 알림음 재생을 위한 설정을 해 줍니다.

저는 미디어를 재생하기위해서 MediaPlayer 객채를 사용하였습니다.

알람음 같은 경우엔 mp3형식으로 미리 res폴더에 담아 두고 

Uri 형식으로 변환하여 .setDataSource의 두번 쨰 인자로 작성해 줍니다.

private var mediaPlayer: MediaPlayer? = null

// onCreate
mediaPlayer = MediaPlayer().apply {
    isLooping = true // 반복재생을 위한 loop 설정
    setOnPreparedListener {
       it.start() // 미디어가 준비되었을떄 실행해줄 리스너
    }
}
.
.
.
// startAlarm
mediaPlayer.setDataSource(
   this.baseContext, 
   준비된 파일의 Uri 포멧
)

mediaPlayer.prepareAsync()
Manifest에 등록
<service
    android:name=".service.AlarmService"
    android:enabled="true"
    android:exported="false" />

AlarmReceiver

BroadcastReceiver 컴포넌트를 상속받는 클래스를 생성해줍니다.

onReceive 함수에선 해당 리시버에 전달받는 액션별로 기능을 추가할수 있습니다.

해당 예시에선 기기가 다시 부팅되었을떄 알람을 재 설정 하는 기능과

AlarmManager로부터 전달받은 이벤트를 Service에 전달해주는 기능으로 구현하였습니다.

즉 Receiver는 중간다리 정도의 역활을 한다고 볼수 있습니다 :)

override fun onReceive(context: Context, intent: Intent) {
    Log.e("RestartAlarmReceiver", "${intent.action}")
    if (intent.action == Intent.ACTION_BOOT_COMPLETED) {
    // 기기 재 부팅 시 알람 재 등록
        coroutineScope.launch {
            fetchAlarmListUseCase()
                .collect {
                    it.onEach { alarm ->
                        if (alarm.isActivated) {
                            AlarmUtil.scheduleAlarm(alarm)
                        } else {
                            AlarmUtil.unscheduleAlarm(alarm.id)
                        }
                    }
                }
        }
    } else {
        AlarmUtil.getAlarmFromIntent(intent).let { alarm ->
            if (alarm.weekTypes.isEmpty()) {
            // 일회성 알람일 경우 (반복요일 x) 알람 비활성화 및 서비스 실행
                coroutineScope.launch {
                    updateAlarmActivationUseCase.invoke(alarm.id to false)
                }
                startAlarmService(context, alarm)
            } else {
                if (checkTodayAlarm(alarm)) {
                // 반복 요일 설정된 알람 중 오늘에 해당하는 알람
                // 서비스 실행 및 알람 재 등록
                    AlarmUtil.scheduleAlarm(alarm)
                    startAlarmService(context, alarm)
                }
            }
        }
    }
}
private fun checkTodayAlarm(alarm: Alarm): Boolean {
    val today = Calendar.getInstance().apply {
        timeInMillis = System.currentTimeMillis()
    }.get(Calendar.DAY_OF_WEEK)

    return alarm.weekTypes.map(WeekType::toNumber).contains(today)
}

private fun startAlarmService(
    context: Context,
    alarm: Alarm,
) {
    val serviceIntent = AlarmService.getIntent(context).let {
        AlarmUtil.putAlarmToIntent(it, alarm).apply {
            action = AlarmService.ACTION_START
        }
    }

    context.startForegroundService(serviceIntent)
}
Manifest에 등록
<receiver
    android:name=".receiver.AlarmReceiver"
    android:enabled="true"
    android:exported="false">
    <intent-filter>
        <action android:name="android.intent.action.BOOT_COMPLETED" />
    </intent-filter>
</receiver>

AlarmManger

기본적으로 타 블로그에서 기술하는

.set

.setExact

.setIdle

함수들을 활용해서 구현해본 테스트 결과

상황별로 알람기능기능으로 사용할수 없는 문제점들이 있었습니다. 

(background에선 전달을 못 한다거나, 0~1분 정도 오차가 있다던가 등)

 

결론은 

.setAlarmClock 함수를 활용하면 해결되는 문제였습니다.

해당 함수는  pendIngIntent 와 AlarmClockInfo 라는 객채 두가지 인자를 받는데요,

크게 신경쓸거 없이 알람이 울릴 시간의 timeMlilli 값과

receiverIntent를 담고있는 pendingIntent 두가지 인자만 전달해주면 됩니다.

알람 매니저 생성
private lateinit var alarmManager: AlarmManager

//init
alarmManager = context.getSystemService()!!
알람 매니저에 알람 등록
@SuppressLint("ScheduleExactAlarm")
fun scheduleAlarm(
    alarm: Alarm,
) {
    val receiverIntent = putAlarmToIntent(AlarmReceiver.getIntent(applicationContext), alarm)

    val pendingIntent = getPendingIntent(receiverIntent, alarm.id)

// 알람에 기입되는 정보는 시간/분/요일 뿐이기에 알람이 울릴 날짜를 금일로 설정한 후 나머지 정보들을 기입
    val calendar = Calendar.getInstance().apply {
        timeInMillis = System.currentTimeMillis()
        set(Calendar.HOUR_OF_DAY, alarm.hour)
        set(Calendar.MINUTE, alarm.minute)
        set(Calendar.SECOND, 0)
    }

// 알람이 설정된 시간이 이미 지났다면, 다음날로 설정
    if (calendar.timeInMillis <= System.currentTimeMillis()) {
        calendar.add(Calendar.DAY_OF_MONTH, 1)
    }

    Log.e("AlarmUtil", "scheduleAlarm: ID:${alarm.id} ${calendar.timeInMillis.toDateFormat()}")

// 알람 매니저에 등록
    alarmManager.setAlarmClock(
        AlarmClockInfo(
            calendar.timeInMillis,
            pendingIntent
        ),
        pendingIntent
    )
}
등록된 알람 취소
fun unscheduleAlarm(
    alarmId: String,
) {
    val receiverIntent = AlarmReceiver.getIntent(applicationContext)

    val pendingIntent = getPendingIntent(receiverIntent, alarmId.toInt())

    Log.e("AlarmUtil", "unscheduleAlarm: Id: $alarmId")

    alarmManager.cancel(pendingIntent)
}

여기서 pendingIntent를 생성할떄 주의할 점은

안드로이드 12 이상부터 PendingIntent의 보안성 문제로 변경점이 있어 flag를 유의해서 설정 해야합니다.

또한 여기에 등록하는 requestCode는 생성한 PendingIntent의 고유 식별자로 사용되기 떄문에,

알람별 고유 식별자 (integer) 형태의 아이디를 활용해 줍니다. 

private fun getPendingIntent(
    intent: Intent,
    requestCode: Int,
): PendingIntent {
    return PendingIntent.getBroadcast(
        applicationContext,
        requestCode,
        intent,
        PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
    )
}

여기까지 작성하셨다면 이제 마무리 단계인데요, 사용한 기능이 많다보니,

Manifest에 설정 해 줄 권한도 꽤나 많습니다. 하나씩 주석으로 설명해 두겠습니다. 

// 진동 사용
<uses-permission android:name="android.permission.VIBRATE" /> 
// 알림(노티)
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
// 재 부팅시
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
// 정확한 알람 설정
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
// 포그라운드 서비스
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
// 알람화면 
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
구현하지 않으신 기능의 권한이라면 제거하시면 됩니다.

 

 

 

여기까지 알람 기능 구현에대해 기술해 보았는데요, 

혹시나 궁금하신점이나 헷갈리는부분이 있으시다면 댓글이나 쪽지로 남겨주시면,

친절히 답변드리도록 하겠습니다 감사합니다 :)

Comments