알림 앱 만들기


Backgraound 작업 종류

  • Immeditate tasks (즉시 실행해야 하는 작업)
    • Thread
    • Handler
    • Kotlin coroutines
  • Deferred tasks (지연된 작업)
    • WorkManager
  • Exact tasks (정시 실행 작업)
    • AlarmManager (이번 실습에서 채택)


AlarmManager


지정된 시간에 pending event를 발생시킴

시간은 Real time과 Elapsed time 모두 지정 가능


Broadcast Receiver


안드로이드 휴대폰의 상태, 베터리 등 시스템 정보를 받아들이는 기능을 함

시스템 말고 외부 앱과 브로드캐스팅도 가능함


AlarmManager를 통해 정해진 시간에 Pending Event를 발생시키고 Broadcast Receiver를 통해 이를 수신해 알람을 띄운다.


  • UI 구성



activity_main.xml

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout 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:background="@color/backgroundBlack"
    tools:context=".MainActivity">

    <View
        android:layout_width="0dp"
        android:layout_height="0dp"
        android:layout_margin="50dp"
        android:background="@drawable/background_white_ring"
        app:layout_constraintBottom_toTopOf="@id/onOffButton"
        app:layout_constraintDimensionRatio="1:1"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <TextView
        android:id="@+id/timeTextView"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:gravity="center"
        android:text="09:03"
        android:textColor="@color/white"
        android:textSize="50sp"
        app:layout_constraintBottom_toTopOf="@id/ampmTextView"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintVertical_chainStyle="packed" />

    <TextView
        android:id="@+id/ampmTextView"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="am"
        android:textColor="@color/white"
        android:textSize="25sp"
        app:layout_constraintBottom_toTopOf="@id/onOffButton"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@id/timeTextView" />

    <Button
        android:id="@+id/onOffButton"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:backgroundTint="@color/backgroundBlack"
        android:text="@string/onAlarm"
        app:layout_constraintBottom_toTopOf="@id/changeTimeButton"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent" />

    <Button
        android:id="@+id/changeTimeButton"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:backgroundTint="@color/backgroundBlack"
        android:text="@string/changeTimeText"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        android:layout_marginBottom="20dp"/>


</androidx.constraintlayout.widget.ConstraintLayout>


background_white_ring.xml

<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="oval">
    <size
        android:height="250dp"
        android:width="250dp"/>

    <stroke
        android:width="1dp"
        android:color="@color/white"/>
</shape>



  • AlarmDisplayModel 구현


AlarmDisplayModel.kt

data class AlarmDisplayModel(
    val hour:Int,
    val minute:Int,
    var onOff:Boolean
){
    val timeText:String
        get(){
            val h = "%02d".format(if(hour<12) hour else hour-12 )
            val m = "%02d".format(minute)
            return "$h:$m"
        }

    val ampmText:String
        get(){
            return if(hour>12) "pm" else "am"
        }

    val onOffButtonText:String
        get(){
            return if(onOff) "알람 끄기" else "알람 켜기"
        }
    fun makeData() = "$hour:$minute"
}


  • 시간 설정 버튼 구현 (SharedPreferences, Calander, TimePickerDialog)


Data Store는 사용하지 않고 SharedPrefreneces를 사용

Data Store와 SharedPrefreneces 차이

initChangeTimeButton() 함수는 시간 설정 버튼을 초기화 해줌(TimePickerDialog를 띄우고 설정된 시간을 saveAlarm() 를 통해 저장)

MainActivity.kt

    // MainActivity OnCreate 밑

    private fun initChangeTimeButton() {
        val changeTimeButton = findViewById<Button>(R.id.changeTimeButton)

        val calendar = Calendar.getInstance()

        changeTimeButton.setOnClickListener{
            TimePickerDialog(this, { picker, hour, minute->

                // 기존 알람을 삭제하는 코드, 밑에서 구현
                // pending event가 설정되어있다면 삭제!
                val pendingIntent = PendingIntent.getBroadcast(this, ALARM_REQUEST_CODE, Intent(this, AlarmReceiver::class.java), PendingIntent.FLAG_NO_CREATE)
                pendingIntent?.cancel()
                
                val model = saveAlarm(hour,minute, false)
                renderModel(model) // 밑에서 구현

            }, calendar.get(Calendar.HOUR_OF_DAY), calendar.get(Calendar.MINUTE), false).show()
        }
    }

    private fun saveAlarm(hour:Int, minute:Int, onOff:Boolean): AlarmDisplayModel{
        val model = AlarmDisplayModel(
            hour=hour,
            minute=minute,
            onOff=onOff
        )

        // SharedPreferences 사용, Data store은 이번에 사용하지 않음
        val sharedPreferences = getSharedPreferences(SHARED_PREFERENCES_NAME, Context.MODE_PRIVATE)

        // Scope Function with() 실행, sharedPreferences.edit() 의 함수들을 스코프 내에서 실행
        with(sharedPreferences.edit()){
            putString(ALARM_KEY, model.makeData())
            putBoolean(ON_OFF_KEY, model.onOff)
            commit()
        }

        return model
    }

    companion object{
        private const val SHARED_PREFERENCES_NAME = "time"
        private const val ALARM_KEY = "alarm"
        private const val ON_OFF_KEY = "onOff"
        private const val ALARM_REQUEST_CODE = 1000
    }


  • Alarm Receiver 구현


BroadcastReceiver 를 상속받아 Broadcast를 수신

onReceive() 를 오버라이딩해 수신 동작을 설정

createNotificationChannel() 함수는 sdk 26 이상 사용자를 위해 알람 채널을 생성

notifyNotification() 함수는 알람을 생성해줌

이전 AlarmManager 실습 참고

AlarmReceiver.kt

import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat

class AlarmReceiver:BroadcastReceiver() {

    override fun onReceive(context: Context?, intent: Intent?) {
        createNotificationChannel(context!!)
        notifyNotification(context!!)
    }

    private fun createNotificationChannel(context: Context) {

        // sdk 26이상부터는 채널이 필요함
        if(Build.VERSION.SDK_INT>= Build.VERSION_CODES.O){
            // 채널 id, 이름, 중요도
            val notificationChannel = NotificationChannel(
                CHANNEL_ID,
                "기상 알림",
                NotificationManager.IMPORTANCE_DEFAULT
            )
            NotificationManagerCompat.from(context).createNotificationChannel( notificationChannel )
        }
    }

    private fun notifyNotification(context: Context){
        with(NotificationManagerCompat.from(context)){
            val builder = NotificationCompat.Builder(context,CHANNEL_ID)
                .setSmallIcon(R.drawable.ic_launcher_foreground)
                .setContentTitle("알람")
                .setContentText("일어날 시간입니다.")
                .setPriority(NotificationCompat.PRIORITY_DEFAULT)

            // 주의 !!!!!!!!! 채널 아이디가 아니라 Noti ID
            notify(NOTIFICATION_ID, builder.build())
        }
    }

    companion object{
        private const val CHANNEL_ID = "1000"
        private const val NOTIFICATION_ID = 100
    }
}


Manifest.xml

<!--리시버 컴포넌트 추가-->
<receiver
    android:name=".AlarmReceiver"
    android:exported="false"/>


  • UI render 및 데이터 가져오기 구현


fetchDataFromSharedPreferences() 함수는 SharedPreferences에 저장되어있는 알람 정보와 Pending Event 정보를 동기화, 불러오기 해줌

renderModel() 함수는 model 정보를 바탕으로 UI를 그려주고 On/Off 버튼에 model 정보를 tag로 삽입

MainActivity.kt

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        initOnOffButton()
        initChangeTimeButton()

        val model = fetchDataFromSharedPreferences()
        renderModel(model)
    }
    
    ...

    private fun fetchDataFromSharedPreferences(): AlarmDisplayModel {
        val sharedPreferences = getSharedPreferences(SHARED_PREFERENCES_NAME, Context.MODE_PRIVATE)

        // nullable 반환이라 null safe 필요
        val timeValue = sharedPreferences.getString(ALARM_KEY,"09:30") ?: "09:30"
        val onOffValue = sharedPreferences.getBoolean(ON_OFF_KEY, false)
        val alarmData = timeValue.split(':')

        val model = AlarmDisplayModel(alarmData[0].toInt(), alarmData[1].toInt(),onOffValue)

        // 예외처리
        val pendingIntent = PendingIntent.getBroadcast(this, ALARM_REQUEST_CODE, Intent(this, AlarmReceiver::class.java), PendingIntent.FLAG_NO_CREATE)
        if(pendingIntent== null && model.onOff){
            // pending event는 없는데 알람이 켜져있는 경우 알람 끄기
            model.onOff = false
        }
        else if( pendingIntent != null && model.onOff.not()){
            // pending event는 있는데 알람이 꺼저있는 경우 pending event 끄기
            pendingIntent.cancel()
        }
        return model
    }

    private fun renderModel(model: AlarmDisplayModel){
        findViewById<TextView>(R.id.ampmTextView).apply{
            text = model.ampmText
        }
        findViewById<TextView>(R.id.timeTextView).apply{
            text = model.timeText
        }
        findViewById<Button>(R.id.onOffButton).apply{
            text = model.onOffButtonText

            // 현재 model을 전역변수로 설정하지 않았다 따라서 버튼에 태그를 달아서 오브젝트를 서정, 불러오기 가능
            tag = model
        }
    }

    ...
}
  • On/Off 버튼 구현


initOnOffButton() 함수로 새로운 버튼의 기능을 설정

onOffButton는 눌릴 때 마다 새로운 model을 생성, 저장한다.

알람이 켜졌을 땐 alarmManager을 통해 알람을 설정, 꺼졌을 땐 알람을 취소한다.

MainActivity.kt

    // onCreate 밑
    private fun initOnOffButton() {
        val onOffButton = findViewById<Button>(R.id.onOffButton)

        onOffButton.setOnClickListener {
            val model = it.tag as? AlarmDisplayModel ?: return@setOnClickListener
            val newModel = saveAlarm(model.hour, model.minute, model.onOff.not())
            renderModel(newModel)
            if(newModel.onOff){
                // 알람이 켜진 경우 -> 알람을 등록
                val calendar = Calendar.getInstance().apply{
                    set(Calendar.HOUR_OF_DAY, newModel.hour)
                    set(Calendar.MINUTE, newModel.minute)

                    // 현재 시각보다 이전의 시간이면 다음날로 설정
                    if(before(Calendar.getInstance())){
                        add(Calendar.DATE, 1)
                    }
                }

                val alarmManager = getSystemService(Context.ALARM_SERVICE) as AlarmManager
                val intent = Intent(this, AlarmReceiver::class.java)
                val pendingIntent = PendingIntent.getBroadcast(this, ALARM_REQUEST_CODE,intent, PendingIntent.FLAG_UPDATE_CURRENT)

                alarmManager.setInexactRepeating(
                    AlarmManager.RTC_WAKEUP,    // 시간 방식
                    calendar.timeInMillis,      // 원하는 시간
                    AlarmManager.INTERVAL_DAY,  // 하루 한번
                    pendingIntent               // pending event 등록
                )
            }else{
                Log.d("tag","cancel")
                // 알람이 꺼진 경우 -> 알람 삭제
                cancelAlarm()
            }
        }
    }