앱 프로젝트 - 11 - 1 (알람 앱) - AlarmManager, Notification( 알림 ), Broadcast receiver, Background 작업(Immediate tasks, Deferred tasks, Exact tasks), Pending Intent, TimePickDialog, SharePreference, Context 란?, Broadcast란?, 데이터 클래스 활용 data class , View의 tag속성, as

하이루·2022년 2월 4일
0

소개

알람 앱에서 Background 작업을 해야하는 이유?

예를들어,
3시간 뒤에 알람이 울리도록 한다고 하면,
알람이 울리려면 앱이 계속 실행되어 있으면서
3시간이 되었는지 체크해야 한다.

하지만 일반적인 방법으로 했을 경우,
3시간 동안 앱이 살아있을 것이라는 보장이 없다.

따라서 Background작업을 통해 시간을 확인하는 기능을 넣는 것으로
앱이 살아있는지 여부와 상관없이 알람을 세팅해주는 것이다.


레이아웃 소개

[ 메인 화면 ]

[ 시간 재설정 버튼 클릭 ]


알아야 할 내용

as 연산자, is 연산자, as? 연산자

--> as 를 통해 형변환을 할 수 있다.

as 연산자는 해당 값을 as로 지정한 타입으로 형변환함

예시 코드 )

        val ss : Float = 0.2F
        val a = ss as Double
        // ss의 타입을 Double로 형변환
        // 만약 형변환이 불가능할 경우 오류 발생

하지만 만약 해당 타입으로 바꿀 수 없으면 ClassCastException이 발생시킴

따라서 as 로 형변환 시키기 전에
is 연산자로 형변환이 가능한지 체크하는 과정이 필요했었음ㅡㅡ,

kotlin에서는 이런 과정을 생략할 수 있는 as? 연산자를 제공하고 있음

예시


		val ss : Float = 0.2F
        
        val a = ss as? Double ?: 66
        // ss를 Double로 형변환, 
        // 형변환 불가능이라면 null반환
        // 엘비스 연산자에 의해 as?가 null반환이면 66이 반환
        // 결과적으로 형변환이 불가능하면 a = 66이 됨

Context란?

흔히 코딩을 하다보면 context 객체를 보게되고, 대부분은 this를 통해 context를 가져오게 될 것이다.

Context는
실행하고 있는 앱의 상태, 맥락과 같은 의미를 담고 있다.

Context가 하는 일

안드로이드 앱이 해당 환경에서
글로벌 정보나
안드로이드 API나
시스템이 관리하고 있는 정보( Resource 파일에 대한 접근, SharedPreference 등등 )과
같은 기능들을 저장해놓은,
그리고 거기에 접근을 할 때 필요한 객체이다.

액티비티에서 this를 통해 Context를 사용할 수 있는 이유

  • 액티비티는 Context를 상속받고 있음

  • 따라서 액티비티에서는 실행하고 있는 환경 자체가
    SharedPreference나 Resource와 같은 영역에 대한 접근이 용이한 상태

그래서 결과적으로 액티비티 자체를 Context와 동일시하는 것이 가능하므로,
액티비티에서 this를 통해 Context를 대신할 수 있는 것

액티비티가 아닌 영역에서의 Context사용

위에 설명했듯이 액티비티는 Context와 동일시 가능하지만,
다른 환경에서는 그것이 불가능하다.

예를 들어,
이번 앱에서 사용하는 BroadcastReceiver()를 구현하는 class의 경우
( 아래에 AlarmReceiver.kt로 생성된 파일 ),

BroadcastReceiver()는 액티비티 환경이 아니기 때문에 Context를 따로 받아와야한다. ( 파라미터로 받아오든, 메소드로 받아오든 )
( 당연하게도 해당 환경에서 this를 통한 접근도 불가능 )

결론

결과적으로
데이터에 접근하는 영역에 있어서는 대부분의 경우 Context가 필수적이며,

  • 액티비티 환경일 경우 this를 통해 접근,
  • 그 외의 환경읠 경우 Context를 받아오는 과정이 필요하다.

하지만 이미 만들어져 있어서 오버라이드하여 사용하는 함수들의 절대적인 대다수( 전부 그럴지는 모르겠음 )는
Context가 필요할 경우, 파라미터로 받아오는 부분이 이미 되어있기 때문에
새로운 모듈을 만드는 것이 아닌 이상 굳이 이 부분에 대해 신경쓸 필요는 없을 것이다.

단, 액티비티가 아닌 영역( 즉, Context를 상속하지 않는 영역 )에서는 this를 통해 Context에 접근할 수 없다는 것만 알고 있으면 된다.


View의 tag 속성

View를 상속하는 컴포넌트들에는 모두 공통적으로 tag라는 속성이 존재한다.

이 속성은 View에서 다른 역할을 하는 속성이 아닌,
일종의 임시 데이터 저장고의 역할을 하는 속성이다.

tag속성은 Object 타입이기 때문에 모든 타입이 들어갈 수 있다 !!!!!!

예를들어


......
 private fun renderView(model: AlarmDisplayModel) {
 
 		val model = 20
 
       findViewById<Button>(R.id.onOffButton).apply {
           
            tag = model

        }
        
}
        
        ......
		val onOffButton = findViewById<Button>(R.id.onOffButton)
        
               onOffButton.setOnClickListener {

            val model = it.tag 
        
        }
        
  • 위의 코드를 보면 renderView 메소드 내부에 정의된 model변수는 지역변수이므로,
    외부에서 정의된 메소드인 onOffButton.setOnClickListener{} 에서는 해당 변수에 접근해서 값을 가져올 수 없음

  • 위의 코드에서는 tag속성을 사용하여,
    onOffButton의 tag속성에 renderView메소드의 지역변수인 model의 값을 저장하였다가
    onOffButton.setOnClickListener{} 메소드에서 tag속성에서 꺼내어 사용하였다.


BackGround 작업

Immediate tasks ( 즉시 실행하해하는 작업 )

--> 앱을 사용하는 도중에 Background에서 처리해야하는 작업

  • Thread

  • Handler

  • Kotlin coroutines
    --> Kotlin에서 지원하는 비동기 프로그램

Deferred tasks ( 지연된 작업 )

--> 앱이 종료되었거나 다시 실행되었더라도 예약된 작업을 실행시키는 작업
--> push수신과 같은 영역에서 이용이 됨

  • WorkManager

Exact tasks ( 정시에 실행해야 하는 작업 )

--> 반복된 시간이나 지정된 간격, 지정된 시간에 PendingIntent를 통해 지정된 작업이 실행되도록 함

  • AlarmManager

Pending Intent란 ?

Intent는 액티비티 간에 데이터를 옮기거나, 액티비티간에 연결고리의 역할을 한다.

여기서 Pending Intent는 이런 Intent를 즉시 실행시키는 것이 아니라 잠시 Pending( 보류 ) 해놓았다가 실행시키는 기능을 Pending Intent라고 한다.

혹은 9장에서 나온 것처럼 Intent를 실행시킬 권한을 제 3자인 Pending Intent에게 넘겨줘서
해당 Pending Intent가 Intent를 실행해야겠다고 판단이 들면 실행시킬 수 있도록 만드는 기능이라고 이해할 수도 있다.


Broadcast란?

기기가 켜져 있는지,
배터리가 얼마나 남았는지,
Wifi를 켰는지 안켰는지 등
시스템에서 일어나는 작업들을 catch해서 작업을 진행해야되는 경우가 있을 수 있다.

예를 들어,
Wifi가 꺼졌을 떄는 다른 알림을 줘야한다든지,
Wifi가 켜졌을 때 사용자의 동작을 catch해서 다른 작업을 해야된든지 하는 앱들이 있을 수 있다.
그런데 이렇게 catch하는 부분들에 대해 실시간으로 Wifi가 켜졌는지 꺼졌는지 확인하는 것은
작업이 많이 필요하고 배터리 소모가 그만큼 심해질 수밖에 없다.
또한 이런 앱들이 많아진다면 그만큼 기기의 부담이 커질 것이다.

따라서 안드로이드에서는 이런 부분들을 앱에서 처리하는 것이 아니라
시스템상에서 해당 작업( 예를들어, Wifi를 켜고 끄는 등등 시스템적인 작업들 )이 있을 때마다
Broadcast를 통해서 해당 작업의 완료를 전파한다.

이제 앱에서는 이렇게 Broadcast로 전파한 내용에 대해
이러이러한 전파내용은 받겠다라고 설정한 Broadcast receiver만 등록하면,
시스템의 동작에 대한 catch가 가능해지는 것이다.

--> 예를 들어, Wifi가 켜졌는지에 대한 Broadcast를 수신하겠다고 Broadcast receiver를 등록하면,
해당 앱에서는 Wifi가 켜졌을 때, Broadcast receiver를 통해 catch하여 대응할 수 있는 것이다.


AlarmManager -> 알람 서비스를 제공하는 Broadcast 기술

  • Real Time(실제 시간)으로 실행시키는 방법 -> RTC_WAKEUP
  • Elapsed Time(기기가 부팅된지부터 얼마나 지났는지)으로 실행시키는 방법 -> ELAPSED_REALTIME_WAKEUP

AlarmManager는 안드로이드에서 제공하는 SystemService의 일환으로
AlarmManager에 설정한 시간이 되면, 설정한 Broadcast receiver에 Broadcasting을 해준다.

AlarmManager 사용 예시

MainAcitivity.kt -> BroadcastReceiver를 실행 + Alarm서비스를 Broadcast로 등록하여 실행


              private val editHour:EditText by lazy {
                  findViewById<EditText>(R.id.editHour)
              }
              
              private val editMinute:EditText by lazy {
                  findViewById<EditText>(R.id.editMinute)
              }


				......


                    // TODO AlarmReceiver를 생성하는 부분
                val pendingIntent = PendingIntent.getBroadcast(
                    this, ALARM_REQUEST_CODE,
                    Intent(this, AlarmReceiver::class.java), PendingIntent.FLAG_UPDATE_CURRENT
                )

                
                //TODO 이 부분 이후부터가 알람 Broadcast를 구성하는 부분
                
                val calendar = Calendar.getInstance().apply {
                    set(Calendar.HOUR_OF_DAY, editHour.text.toInt())
                    set(Calendar.MINUTE, editMinute.text.toInt())

                    // 캘린터에 세팅한 시간이 지금시간보다 이전이라면, 내일 해당 시간이라는 의미이므로 Day를 +1 해줌
                    if (before(Calendar.getInstance())) {
                        add(Calendar.DATE, 1)
                    }

                }
                

                // 시스템의 알람서비스를 가져와서 AlarmManager로 형변환
                val alarmManager = getSystemService(Context.ALARM_SERVICE) as AlarmManager
                
                
               // 아래에 설명있음 -> 기기의 수면모드를 회피하기 위한 메소드
               // alarmManager.setAndAllowWhileIdle()
               // alarmManager.setExactAndAllowWhileIdle()
                
                

                alarmManager.setInexactRepeating(

                    // 시간을 어떻게 셀 것인지 정함 ㅡ,
                    // RTC_WAKEUP -> 실제 시간 기준으로 wakeup ( 절대시간 )
                    // ELAPSED_REALTIME_WAKEUP -> 스마트폰이 부팅된 이후부터 시간을 기준으로 wake upㅡ, 좀더 권장됨,, 이유는 확인해봐야할듯
                    AlarmManager.RTC_WAKEUP,
                    calendar.timeInMillis,
                    AlarmManager.INTERVAL_DAY,
                    pendingIntent
                )

......

    companion object {
        private const val ALARM_REQUEST_CODE = 1000
    }
  • 이 부분에서 Pending Intent의 getBroadcast()메소드를 사용하여
    Broadcast Receiver를 실행시키고 있음

                      // TODO AlarmReceiver를 생성하는 부분
                  val pendingIntent = PendingIntent.getBroadcast(
                      this, ALARM_REQUEST_CODE,
                      Intent(this, AlarmReceiver::class.java), PendingIntent.FLAG_UPDATE_CURRENT
                  )
                  
    ......
    
      companion object {
          private const val ALARM_REQUEST_CODE = 1000
      }
    
    • AlarmReceiver.kt파일에 코딩한 BroadcastReceiver를 실행시키고 있음

    • PendingIntent.getBroadcast()는
      첫번째 파라미터로 Context를 받고,
      두번째 파라미터로 Intent 요청에 대한 식별코드를 받고 (임의로 설정),
      세번째 파라미터로 실행시킬 Intent를 받고 ( Intent에 BroadcastReceiver가 정의된 파일 설정 )
      네번째 파라미터로 Flag를 받는다.

    • 여기서 네번째 파라미터인 Flag는
      이미 해당 BroadcastReceiver가 실행되어 존재할 경우
      어떻게 할 것인가에 대한 부분이다.

      1. FLAG_ONE_SHOT
        --> 존재하지 않을 경우 새로 생성
        해당 PendingIntent는 한번만 사용 가능
      2. FLAG_NO_CREATE
        --> 존재하지 않을 경우 getBroadcast()는 null반환
        이미 존재할 경우, 기존의 것을 가져옴
      3. FLAG_CANCEL_CURRENT
        --> 존재하지 않을 경우 새로 생성
        이미 존재할 경우, 기존의 것을 제거하고 해당 BroadcastReceiver실행,
      4. FLAG_UPDATE_CURRENT
        --> 존재하지 않을 경우 새로 생성
        이미 존재할 경우, 기존의 것에 업데이트
      5. FLAG_IMMUTABLE
        --> Immutable하게 생성함 ( 수정 불가능하게 생성 )

      --> BroadCastReceiver에 대한 자세한 설명은 더 아래에 있음

  • 이 부분에서 Calendar 클래스를 사용하여
    AlarmManager에 Setting하기 위한 시간을 구성하고 있음

                  private val editHour:EditText by lazy {
                     findViewById<EditText>(R.id.editHour)
                 }
                 
                 private val editMinute:EditText by lazy {
                     findViewById<EditText>(R.id.editMinute)
                 }
    
    			......
    
                   val calendar = Calendar.getInstance().apply {
                       set(Calendar.HOUR_OF_DAY, editHour.text.toInt())
                       set(Calendar.MINUTE, editMinute.text.toInt())
    
          // 캘린터에 세팅한 시간이 지금시간보다 이전이라면, 내일 해당 시간이라는 의미이므로 Day를 +1 해줌
                       if (before(Calendar.getInstance())) {
                           add(Calendar.DATE, 1)
                       }
    
                   }
    
    • Calendar.getInstance()는 현재 시간을 세팅시킨 Calendar객체를 반환하는 메소드이다.
  • 여기서는 구현하지 않았지만, 기기의 수면모드를 회피하는 방법

    
                  // alarmManager.setAndAllowWhileIdle()
                  // alarmManager.setExactAndAllowWhileIdle()
    
    • 6버전 이상부터는 사용자가 기기를 장시간 사용하지 않을 경우( 잔다던가 하는 이유로 ),
      수면모드에 돌입함

      수면모드에 돌입하면, 배터리 소모를 줄이기 위해 Background 작업이 모두 중단됨
      --> 즉, 알람도 멈춤

    • 이를 방지하고ㅡ, 반드시 알람이 울리도록 하기 위해서는
      위의 2개의 메소드 중 하나를 사용해야 함

    • 또는 정확한 시간에 Push알림을 보내는 것으로 기기를 수면모드에서 깨워서, push알림을 받은 순간부터 알람을 Background에 재설정하는 식으로 구현하는 방법도 있음

  • 이 부분에서 AlarmManager를 이용하여 Alarm Service를 Broadcast로 등록한다.

                 val alarmManager = getSystemService(Context.ALARM_SERVICE) as AlarmManager
    
                   alarmManager.setInexactRepeating(
    
                       // 시간을 어떻게 셀 것인지 정함 ㅡ,
                       // RTC_WAKEUP -> 실제 시간 기준으로 wakeup ( 절대시간 )
                       // ELAPSED_REALTIME_WAKEUP -> 스마트폰이 부팅된 이후부터 시간을 기준으로 wake upㅡ, 좀더 권장됨,, 이유는 확인해봐야할듯
                       AlarmManager.RTC_WAKEUP,
                       calendar.timeInMillis,
                       AlarmManager.INTERVAL_DAY,
                       pendingIntent
                   )
    
    • SystemService에서 알람서비스를 가져와서 AlarmManager로 형변환시켜주고 있음

                    val alarmManager = getSystemService(Context.ALARM_SERVICE) as AlarmManager
    • AlarmManager를 통해 Broadcast에 알람을 등록하고 있음

                      alarmManager.setInexactRepeating(
                        AlarmManager.RTC_WAKEUP,
                        calendar.timeInMillis,
                        AlarmManager.INTERVAL_DAY,
                        pendingIntent
                    )
      
      • AlarmManager는 Broadcast에 알람을 등록하기 위한 여러 메소드를 가지고 있음

        1. setExact()
          --> 정확한 시간에 알람을 Broadcast로 발송
        2. setRepeating()
          --> 시간 간격으로 반복해서 알람을 Broadcast로 발송
        3. setInexactRepeating()
          --> 시간 간격을 두고, 비정확한(몇 십초정도 오차가 있을 수 있음) 시간에 알람을 Broadcast로 발송
      • 왜 1번 같은 정확한 시간이 아닌 3번의 비 정확한 시간을 사용하는 것일까?

        그 이유는 정확한 시간을 사용하기 위해서는 기기가 더 빈번하게 체크를 수행해야하기 때문
        --> 고로 기기에 더 많은 부담이 생김

        따라서 아주 정확해야하는 상황이 아니라면 비정확한 알람을 사용하는 것을 추천한다.

      • 이번에 사용한 setInexactRepeating()메소드는
        첫번째 파라미터로 시간을 세는 기준??,
        두번째 파라미터로 Calendar를 통해 설정한 시간,
        세번째 파라미터로 알람의 반복 주기,
        네번째 파라미터로 PendingIntent
        ( 해당 알람으로 Broadcasting할 때ㅡ, 전달받기 위한 BroadcastReceiver를 담은 PendingIntent )를 받는다.

Broadcast Receiver -> 위의 AlarmManager를 통한 Broadcast가 왔을 때, 그것에 반응하기 위한 Receiver

예시 ) AdroidManifest.xml -> Broadcast Receiver를 등록해야함

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

    <application
        android:allowBackup="true"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:supportsRtl="true"
        android:theme="@style/Theme.Aop_part3_chapter11">
        <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>
        
        <receiver android:name=".AlarmReceiver"
            android:exported="false"/>
        
    </application>

</manifest>
  • 해당 부분에서 BroadcastReceiver 등록

           <receiver android:name=".AlarmReceiver"
               android:exported="false"/>

예시 ) AlarmReceiver.kt -> Broadcast Receiver 구현


package com.example.aop_part3_chapter11

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(){

    // PendingIntent를 통해 BroadcastReceiver가 등록된 이후에,
    // AlarmManager에 의한 Broadcast가 발생하면 호출되는 메소드
    override fun onReceive(context: Context, intent: Intent) {

        creatNotificationChannel(context)
        notifyNotification(context)

    }

    private fun creatNotificationChannel(context: Context){

        if(Build.VERSION.SDK_INT>= Build.VERSION_CODES.O){
            val notificationChannel = NotificationChannel(
                NOTIFICATION_CHANNEL_ID,
                "기상 알람",
                NotificationManager.IMPORTANCE_HIGH
            )

            NotificationManagerCompat.from(context)
                .createNotificationChannel(notificationChannel)
        }

    }

    private fun notifyNotification(context: Context){

        with(NotificationManagerCompat.from(context)){
            val build = NotificationCompat.Builder(context, NOTIFICATION_CHANNEL_ID)
                .setContentTitle("알람")
                .setContentText("일어날 시간입니다.")
                .setSmallIcon(R.drawable.ic_launcher_foreground)
                .setPriority(NotificationCompat.PRIORITY_HIGH)

            notify(NOTIFICATION_ID, build.build())
        }


    }

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

}
  • 이 부분을 보면 해당 클래스는 BroadcastReceiver()를 상속받음

    class AlarmReceiver : BroadcastReceiver(){

    --> BroadcastReceiver를 구현하는 클래스

  • 이 부분을 보면 해당 클래스는 Context를 this 키워드로 불러오지 않음
    --> BroadcastReceiver()는 액티비티가 아니며,
    따라서 Context의 상속을 받지 않으므로 액티비티에서 사용했던 것과는 다르게
    Context를 따로 구해서 사용해야 한다.
    ( 여기서는 PendingIntent가 해당 Receiver를 실행할때 Context를 전달해줌 )

          creatNotificationChannel(context)
           notifyNotification(context)
  • BroadcastReceiver가 재정의해야하는 onReceive() 메소드
    --> PendingIntent를 통해 BroadcastReceiver가 등록된 이후에,
    AlarmManager에 의한 Broadcast가 발생하면 호출되는 메소드

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

    예시 PendingIntent를 이용하여 등록된 BroadcastReceiver를 해제하기

    --> PendingIntent를 이용한 BroadcastReceiver 등록에 대한 부분은 위쪽에 정리해놓았다.

            val pendingIntent = PendingIntent.getBroadcast(
              this,
              ALARM_REQUEST_CODE,
              Intent(this, AlarmReceiver::class.java),
              PendingIntent.FLAG_NO_CREATE
    
          )
    
          pendingIntent?.cancel()
    
  • 위의 코드와 같이 PendingIntent로 해당 식별코드( 위 코드의 ALARM_REQUEST_CODE )의 BroadcastReceiver를 불러온 다음
    cancel() 메소드를 사용하여 해제하면된다.


TimePickDialog

TimaPicker라고 하는 시간을 설정하는 기능을 Dialog에 넣어준 것

예시


            // 현재 시간을 가져옴
            val calendar = Calendar.getInstance()

            TimePickerDialog(
                this,
                { picker, hour, minute ->
                    // TimePickDialog를 통해 사용자가 시간을 설정하면 호출되는 리스너
                    // 첫번째 파라미터로 TimePickDialog에 TimePick을 하게 해주는 시계인 TimePicker가 들어오고, 두번째 파라미터로 사용자가 선택한 시간이, 세번쨰 파라미터로 사용자가 선택한 분이 들어온다.

                    val model = saveAlarmModel(hour, minute, false)
                    // 데이터를 저장한다.

                    renderView(model)
                    // 뷰를 업데이트 한다.


                    // 기존에 있던 알람을 삭제한다.
                    cancelAlarm()


                },
                calendar.get(Calendar.HOUR_OF_DAY),
                calendar.get(Calendar.MINUTE),
                false
            ).show()
  • 첫번째 파라미터로 Context

  • 두번째 파라미터로 TimaPickerDialog에 대한 리스너
    -> 위에 예시에서는 람다함수로 구성

  • 세번째 파라미터로 TimePicker에 처음에 나타날 시간

  • 네번째 파라미터로 TimePicker에 처음에 나타날 분

  • 다섯번째 파라미터로 기준을 24시로 할지 12시로 할지 여부( true면 24시간 )롤 받는다.


데이터 클래스 활용 ( data class )

이번 앱에서의 data class 예시이다.

package com.example.aop_part3_chapter11

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) "AM" else "PM"
    }

    val onOffText: String
    get(){
        return if (onOff) "알람 끄기" else "알람 켜기"
    }

    fun makeDataForDB(): String {
        return "$hour:$minute"
    }
}
  • 데이터 클래스에서 데이터를 가져갈 때,
    데이터를 다루기 쉽게 데이터를 가공하여 내보내는 변수들을 정의

    예를 들어 hour가 9일 경우 "09"로 텍스트에 나타낼 것이므로,
    9를 "09"로 바꾸어 반환시켜주는 변수를 정의
    --> 위 코드에서 timeText변수의 getter를 변환시켜서 구현

  • 혹은 데이터의 값에 따라 달리지는 내용을 여기서 다뤄서 사용하기 편하게 만듬

    예를 들어 hour의 값에 따라 am,pm이 바뀌는 것
    --> 위 코드에서 ampmText변수의 getter를 변환시켜서 구현

결국 데이터 클래스를 정의할 때,
데이터 클래스의 데이터들을 다루는 부분
( 가공하여 반환하거나, 데이터를 분석하여 결과값을 반환하거나 등등 )들을
여기서 정의해주면 사용할 때 편하다.


코드 소개

MainActivity.kt


package com.example.aop_part3_chapter11

import android.app.AlarmManager
import android.app.PendingIntent
import android.app.TimePickerDialog
import android.content.Context
import android.content.Intent
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.widget.Button
import android.widget.EditText
import android.widget.TextView
import android.widget.TimePicker
import org.w3c.dom.Text
import java.util.*

class MainActivity : AppCompatActivity() {


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

        //todo step0 뷰를 초기화 해주기
        initOnOffButton()
        initChamgeAlarmTimeButton()

        //todo step1 데이터 가져오기
        val model = fetchDataFromSharedPreferences()

        //todo step2 뷰에 데이터 그려주기
        renderView(model)

    }

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

            // renderView메소드에서 OnOffButton의 tag에 저장해놓았던 AlarmDisplayModel을 가져와서 as를 통해 형변환
            // ?를 통해 NullSafe 해주고, Null일 경우 대비해 앨비스 연산자로 리턴시킴
            val model = it.tag as? AlarmDisplayModel ?: return@setOnClickListener
            val newModel = saveAlarmModel(model.hour, model.minute, model.onOff.not())

            renderView(newModel)


            // 데이터를 확인을 한다.
            //온인지 오프인지에 따라 작업을 다르게 처리한다.
            if (newModel.onOff) {
                // 켜진경우 알람을 등록
                    
                    // TODO AlarmReceiver를 생성하는 부분
                val pendingIntent = PendingIntent.getBroadcast(
                    this, ALARM_REQUEST_CODE,
                    Intent(this, AlarmReceiver::class.java), PendingIntent.FLAG_UPDATE_CURRENT
                )

                
                //TODO 이 부분 이후부터가 알람 Broadcast를 구성하는 부분
                
                val calendar = Calendar.getInstance().apply {
                    set(Calendar.HOUR_OF_DAY, newModel.hour)
                    set(Calendar.MINUTE, newModel.minute)

                    // 캘린터에 세팅한 시간이 지금시간보다 이전이라면, 내일 해당 시간이라는 의미이므로 Day를 +1 해줌
                    if (before(Calendar.getInstance())) {
                        add(Calendar.DATE, 1)
                    }

                }
                

                // 시스템의 알람서비스를 가져와서 AlarmManager로 형변환
                val alarmManager = getSystemService(Context.ALARM_SERVICE) as AlarmManager

                // alarmManager로 실행시키는 메소드는 여러개 있는데 이번에는 정확한 시간에 그리고 반복해서 나타나는 메소드로 실행
                // 첫번쨰 파라미터로 알람이 울리는 시간에 대한 기준을 받고, 두번째 파라미터로 언제 알람이 발동할 것인지에 대한 값을 받고( 위에서 Calendar객체에 세팅한 시간 ), 세번째 파라미터로 반복 주기를 받고, 네번째 파라미터로 Broadcast를 보내줄 PendingIntent를 받는다.
                // 이렇게 되면 하루에 한번씩 PendingIntent가 실행되고, 아래의 캘린더에 등록한 시간대로 실행된다.
                alarmManager.setInexactRepeating(

                    // 시간을 어떻게 셀 것인지 정함 ㅡ,
                    // RTC_WAKEUP -> 실제 시간 기준으로 wakeup ( 절대시간 )
                    // ELAPSED_REALTIME_WAKEUP -> 스마트폰이 부팅된 이후부터 시간을 기준으로 wake upㅡ, 좀더 권장됨,, 이유는 확인해봐야할듯
                    AlarmManager.RTC_WAKEUP,
                    calendar.timeInMillis,
                    AlarmManager.INTERVAL_DAY,
                    pendingIntent
                )

            } else {
                // 꺼진 경우 알람을 등록
                cancelAlarm()

            }

        }

    }

    private fun initChamgeAlarmTimeButton() {
        val changeAlarmButton = findViewById<Button>(R.id.changeAlarmTimeButton)
        changeAlarmButton.setOnClickListener {


            // 현재 시간을 가져옴
            val calendar = Calendar.getInstance()

            // TimaPickDialogㅡ, TimePicker를 Dialog로 띄워줘서 사용자가 시간을 설정할 수 있게 만듬
            // 첫번째 파라미터로 현재 위치, 두번째 파라미터로 TimaPickerDialog에 대한 리스너, 세번째 파라미터로 TimePicker에 처음에 나타날 시간, 네번째 파라미터로 TimePicker에 처음에 나타날 분, 다섯번째 파라미터로 기준을 24시로 할지 12시로 할지 여부( true면 24시간 )롤 받는다.
            TimePickerDialog(
                this,
                { picker, hour, minute ->
                    // TimePickDialog를 통해 사용자가 시간을 설정하면 호출되는 리스너
                    // 첫번째 파라미터로 TimePickDialog에 TimePick을 하게 해주는 시계인 TimePicker가 들어오고, 두번째 파라미터로 사용자가 선택한 시간이, 세번쨰 파라미터로 사용자가 선택한 분이 들어온다.

                    val model = saveAlarmModel(hour, minute, false)
                    // 데이터를 저장한다.

                    renderView(model)
                    // 뷰를 업데이트 한다.


                    // 기존에 있던 알람을 삭제한다.
                    cancelAlarm()


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

        }

    }

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

        val sharePreferences = getSharedPreferences(SHARED_PREFERENCES_NAME, Context.MODE_PRIVATE)

        // 이 경우 with함수를 사용하여 sharePreferences의 edit()을 해줬기 때문에 apply가 자동으로 되지 않는다. 따라서 commit()을 명시해줘야한다.
        with(sharePreferences.edit()) {
            putString(ALARM_KEY, model.makeDataForDB())
            putBoolean(ONOFF_KEY, model.onOff)
            commit()
        }

        return model
    }

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

        // getString으로 받아오면 nullable하게 되므로, 엘비스 연산자를 이용해서 nullsafe로 만듬
        val timeDBValue = sharedPreferences.getString(ALARM_KEY, "9:30") ?: "9:30"
        val onOFfDBValue = sharedPreferences.getBoolean(ONOFF_KEY, false)
        val alarmData = timeDBValue.split(":")

        val alarmModel = AlarmDisplayModel(
            hour = alarmData[0].toInt(),
            minute = alarmData[1].toInt(),
            onOff = onOFfDBValue
        )

        // 보정 보정 예외처리 --> 실제 알람이 백그라운드에 있는지 Broadcast를 통해 확인하여 레이아웃의 OnOff와 비교해야함

        //PendingIntent를 통해 Broadcast를 가져옴ㅡ, 현재 알람이 벡그라운드에 세팅되어 있는지 확인
        // Flagㅡ, 여러가지 종류가 있음, 우리가 이번에 이용할 것은 2종류 1. 없으면 없는대로 놔두는 것( PendingIntent.FLAG_NO_CREATE ), 2. 없으면 만들고 있으면 업데이트 하는 것 ( PendingIntent.FLAG_UPDATE_CURRENT )
        // 아래의 코드에선 PendingIntent.FLAG_NO_CREATE로 Flag를 설정했으므로, 해당하는 데이터가 없다면 null이 반환됨
        val pendingIntent = PendingIntent.getBroadcast(
            this,
            ALARM_REQUEST_CODE,
            Intent(this, AlarmReceiver::class.java),
            PendingIntent.FLAG_NO_CREATE
        )

        if ((pendingIntent == null) and alarmModel.onOff) {
            // 알람은 꺼져있는데, 데이터를 켜져있는 경우
            alarmModel.onOff = false
        } else if ((pendingIntent != null) and alarmModel.onOff.not()) {
            // 알람은 켜져있는데, 데이터는 꺼져있는 경우

            // 알람을 취소함
            pendingIntent?.cancel()
        }

        return alarmModel

    }

    private fun renderView(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.onOffText
            tag = model
            // tag는 View에서 사용할 수 있는 무엇이든 넣을 수 있는 임시저장소 느낌임ㅡ, 나중에 다시 사용하기 위해 model객체를 tag에 임시 저장
        }
    }

    private fun cancelAlarm() {

        val pendingIntent = PendingIntent.getBroadcast(
            this,
            ALARM_REQUEST_CODE,
            Intent(this, AlarmReceiver::class.java),
            PendingIntent.FLAG_NO_CREATE

        )

        pendingIntent?.cancel()

    }

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


}

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"
    tools:context=".MainActivity">

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

    <TextView
        android:id="@+id/timeTextView"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="09:30"
        android:textColor="@color/white"
        android:textSize="50sp"
        app:layout_constraintBottom_toTopOf="@id/ampmTextView"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="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:text="@string/on_alram"
        app:layout_constraintBottom_toTopOf="@id/changeAlarmTimeButton"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent" />

    <Button
        android:id="@+id/changeAlarmTimeButton"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:text="@string/change_time"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent" />


</androidx.constraintlayout.widget.ConstraintLayout>

AlarmDisplayModel.kt -> 알람 데이터 클래스


package com.example.aop_part3_chapter11

data class AlarmDisplayModel(
    val hour: Int,
    val minute: Int,
    var onOff: Boolean
) {
//     데이터 클래스에서 데이터를 가져갈 때, 데이터를 다루기 쉽게
//     데이터를 가공하여 내보내는 변수들을 정의
//     예를 들어 hour가 9일 경우 "09"로 텍스트에 나타낼 것이므로, 9를 "09"로 바꾸어 반환시켜주는 변수 ㅡㅡ, 아래의 timeText변수
//
//     혹은 데이터의 값에 따라 달리지는 내용을 여기서 다뤄서 사용하기 편하게 만듬
//     -> 예를 들어 hour의 값에 따라 am,pm이 바뀌는 것  ㅡㅡ, 아래의 ampmText변수
//
//     결국 데이터 클래스를 정의할 때, 데이터 클래스의 데이터들을 다루는 부분( 가공하여 반환하거나, 데이터를 분석하여 결과값을 반환하거나 )들을 여기서 정의해주면 사용할 때 편하다.

    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) "AM" else "PM"
    }

    val onOffText: String
    get(){
        return if (onOff) "알람 끄기" else "알람 켜기"
    }

    fun makeDataForDB(): String {
        return "$hour:$minute"
    }
}

AlarmReceiver.kt -> Broadcast Receiver를 구현한 클래스

package com.example.aop_part3_chapter11

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(){

    // PendingIntent를 통해 Broadcast가 수신되었을 때 호출되는 메소드
    override fun onReceive(context: Context, intent: Intent) {

        // 이전에서는 액티비티 자체가 context라고 볼 수 있었기 때문에 Context대신에 this를 사용할 수 있었지만,
        // BroadcastReceiver는 액티비티가 아니기 때문에 context를 따로 받아와야함
        creatNotificationChannel(context)
        notifyNotification(context)

    }

    private fun creatNotificationChannel(context: Context){

        if(Build.VERSION.SDK_INT>= Build.VERSION_CODES.O){
            val notificationChannel = NotificationChannel(
                NOTIFICATION_CHANNEL_ID,
                "기상 알람",
                NotificationManager.IMPORTANCE_HIGH
            )

            NotificationManagerCompat.from(context)
                .createNotificationChannel(notificationChannel)
        }

    }

    private fun notifyNotification(context: Context){

        with(NotificationManagerCompat.from(context)){
            val build = NotificationCompat.Builder(context, NOTIFICATION_CHANNEL_ID)
                .setContentTitle("알람")
                .setContentText("일어날 시간입니다.")
                .setSmallIcon(R.drawable.ic_launcher_foreground)
                .setPriority(NotificationCompat.PRIORITY_HIGH)

            notify(NOTIFICATION_ID, build.build())
        }


    }

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

}

background_white_ring.xml -> 알람 배경을 위핸 shape drawable

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

    <size
        android:width="250dp"
        android:height="250dp"/>

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


    <solid
        android:color="@color/background_black"/>

</shape>

AndroidManifest.xml

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

    <application
        android:allowBackup="true"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:supportsRtl="true"
        android:theme="@style/Theme.Aop_part3_chapter11">
        <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>

        <receiver android:name=".AlarmReceiver"
            android:exported="false"/>

    </application>

</manifest>
profile
ㅎㅎ

1개의 댓글

comment-user-thumbnail
2022년 5월 16일

지리네요 포스터가!!
이거 sdk 31 버전넘어오면서 pendingInten 부분 분기 처리 하셔야하는데 혹시 하셨나요? 전 못해서 ㅠㅠ

답글 달기