Notification & WorkManager 연결

읽기 전

  • 불필요한 코드나 잘못 작성된 내용에 대한 지적은 언제나 환영합니다.
  • 개인적으로 사용해보면서 배운 점을 정리한 글입니다.
  • 전체 프로젝트 코드는 깃헙 링크 - MyNotification에 올려두겠습니다. 참고해주세요.

구현하려는 파트

사전에 저장된 시간 값에 따라 두 가지 알림을 별도로 생성하는 기능을 구현하려 한다. 먼저 Periodic 요청으로 주기적 요청을 하되 이벤트 시간 여부를 체크하여 OneTime 요청으로 정확한 시간에 알림을 생성하는 방식이다. WorkManager 개발에 참여한 개발자가 포럼에서 매일 특정 시간에 알람을 띄울 때 권장하는 방식으로 발표하였기에 채택하였다.

사전작업

Build.gradle (Module import)

dependencies {
    def work_version = "2.4.0"
    implementation "androidx.work:work-runtime:$work_version"
}

Constants.class - 이벤트 시간, Worker Name

public class Constants {
    // 알림 설정 Preference Key 값
    public static final String SHARED_PREF_NOTIFICATION_KEY = "Notification Value";

    // 알림 채널 ID 값
    public static final String NOTIFICATION_CHANNEL_ID = "10001";

    // 한국 TimeZone
    public static final String KOREA_TIMEZONE = "Asia/Seoul";

    // 챌린지 랭킹 시작 시각
    public static final Integer A_MORNING_EVENT_TIME = 8;
    public static final Integer A_NIGHT_EVENT_TIME = 20;
    public static final Integer B_MORNING_EVENT_TIME = 9;
    public static final Integer B_NIGHT_EVENT_TIME = 21;

    // 푸시알림 허용 Interval 시간
    public static final Integer NOTIFICATION_INTERVAL_HOUR = 1;

    // 백그라운드 work Unique 이름
    public static final String WORK_A_NAME = "Challenge Notification";
    public static final String WORK_B_NAME = "Ranking Notification";
}

푸시 알림 활성화 여부 값을 저장하여 앱 재시작 시에도 유지하게끔 해주는 SharedPreference의 키 값, Notification Channel ID 값, 앱의 이벤트 기준은 한국 시간대이므로 TimeZone 값, 각 이벤트의 오전/오후 이벤트 시간, 알림생성 범위 Interval 시간, 백그라운드에서 주기적 요청을 담당할 Worker들의 Unique Name을 저장하였다. 이 값들은 여러 class에서 사용되기에 Constants.class에서 일괄적으로 관리하게끔 하였다.

푸시알림 설정 관리

Switch를 SecondActivity에 추가한 뒤 on/off에 따라 알림 활성화 여부를 SharedPreference로 관리한다. PreferenceHelper.class를 생성하여 값을 저장, 조회할 수 있는 코드를 작성한 뒤 Switch가 활성화될 때 true 값을 저장하도록 리스너를 정의한다.

PreferenceHelper.class

public class PreferenceHelper {
    private static final String DEFAULT_SHARED_PREF_FILE_NAME = "sample preference";
    private static final Boolean DEFAULT_BOOLEAN_VALUE = false;

    private static SharedPreferences getPreferences(Context context) {
        return context.getSharedPreferences(DEFAULT_SHARED_PREF_FILE_NAME, Context.MODE_PRIVATE);
    }
    public static void setBoolean(Context context, String key, Boolean value) {
        SharedPreferences prefs = getPreferences(context);
        SharedPreferences.Editor editor = prefs.edit();
        editor.putBoolean(key, value);
        editor.commit();
    }

    public static Boolean getBoolean(Context context, String key) {
        SharedPreferences prefs = getPreferences(context);
        return prefs.getBoolean(key, DEFAULT_BOOLEAN_VALUE);
    }

    public static void removeKey(Context context, String key) {
        SharedPreferences prefs = getPreferences(context);
        SharedPreferences.Editor edit = prefs.edit();
        edit.remove(key);
        edit.commit();
    }

    public static void clear(Context context) {
        SharedPreferences prefs = getPreferences(context);
        SharedPreferences.Editor edit = prefs.edit();
        edit.clear();
        edit.commit();
    }
}

SecondActivity - Switch 리스너 관련 코드

    // 푸시알림 설정
    private void initSwitchLayout(final WorkManager workManager) {
        switchActivateNotify = (CompoundButton) findViewById(R.id.switch_second_notify);
        switchActivateNotify.setChecked(PreferenceHelper.getBoolean(getApplicationContext(), Constants.SHARED_PREF_NOTIFICATION_KEY));
        switchActivateNotify.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() {
            @Override
            public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) {
                if (isChecked) {
                    PreferenceHelper.setBoolean(getApplicationContext(), Constants.SHARED_PREF_NOTIFICATION_KEY, true);
                } else {
                    PreferenceHelper.setBoolean(getApplicationContext(), Constants.SHARED_PREF_NOTIFICATION_KEY, false);
                }
            }
        });
    }

푸시알림 백그라운드 등록 사전작업

Switch가 활성화되면 알림 사이클을 유지하기 위한 백그라운드 작업을 등록해야 하는데 그 전에 혹시나 알림 채널이 생성되어 있지 않으면 Null에러가 발생할 확률이 미약하나마 존재하므로 관련 메소드를 추가한 뒤 생성된 상태일 경우 등록작업을 수행하고 그렇지 않으면 채널생성을 진행한다. 채널 생성 관련 내용은 Android | 특정 시간에 푸시알림 보내기 #001 - 알림 설정을 참고바란다.

NotificationHelper.class - 채널 생성 체크

    public static Boolean isNotificationChannelCreated(Context context) {
        try {
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
                NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
                return notificationManager.getNotificationChannel(NOTIFICATION_CHANNEL_ID) != null;
            }
            return true;
        } catch (NullPointerException nullException) {
            Toast.makeText(context, "푸시 알림 기능에 문제가 발생했습니다. 앱을 재실행해주세요.", Toast.LENGTH_SHORT).show();
            return false;
        }
    }

SecondActivity - Switch리스너 채널 생성 체크

    private void initSwitchLayout(final WorkManager workManager) {
        switchActivateNotify = (CompoundButton) findViewById(R.id.switch_second_notify);
        switchActivateNotify.setChecked(PreferenceHelper.getBoolean(getApplicationContext(), Constants.SHARED_PREF_NOTIFICATION_KEY));
        switchActivateNotify.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() {
            @Override
            public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) {
                if (isChecked) {
                    boolean isChannelCreated = NotificationHelper.isNotificationChannelCreated(getApplicationContext());
                    if (isChannelCreated) {
                        PreferenceHelper.setBoolean(getApplicationContext(), Constants.SHARED_PREF_NOTIFICATION_KEY, true);
                    } else {
                        NotificationHelper.createNotificationChannel(getApplicationContext());
                    }
                } else {
                    PreferenceHelper.setBoolean(getApplicationContext(), Constants.SHARED_PREF_NOTIFICATION_KEY, false);
                }
            }
        });
    }

푸시알림 백그라운드 등록

채널 생성을 보장받은 경우 주기적으로 작동하는 PeriodicWorkRequest를 사용하여 등록을 진행한다. 오전/오후에 나눠서 알림을 생성할 예정이므로 반복 간격을 12시간, 그리고 제한시간을 5분으로 설정하여 12시간 간격이되 마지막 5분 동안 Event을 발생시키도록 정의하였다. 그리고 PeriodicWorkRequest를 등록하면 현시점부터 반복주기가 적용되기 때문에 반복주기 내에 띄울 Event가 반드시 1개 존재하여 OneTimeWorkRequest를 등록해야 한다. 우선 이렇게 등록한 뒤 Event가 발생한 뒤에 자세한 동작은 이후 작성할 Worker 클래스에서 정의하면 된다.

만약 푸시알림 활성화가 되었는데 기기에서 백그라운드 작업이 제거되었을 가능성이 있으므로 미리 정해둔 UniqueWorkName에 대한 동작 정보를 조회한 뒤 종료된 상태면 다시 등록하고 생성된 적이 없으면 Null 에러가 raise되므로 이를 catch하여 역시나 작업을 등록해준다. 다른 이벤트 등록 메소드도 WorkName만 다르게 동일한 방식으로 정의한다. 다른 백그라운드 동작이 추가될 것을 고려하여 별도 메소드로 분리한 뒤 하나의 메소드로부터 일괄적으로 요청받도록 했다. 마지막으로 enqueueUniquePeriodicWork를 사용하여 한 개의 이벤트에 반드시 단 한개의 Worker만 붙도록 하였다.

NotificationHelper.class - 백그라운드 등록

    public static void setScheduledNotification(WorkManager workManager) {
        setANotifySchedule(workManager);
        setBNotifySchedule(workManager);
    }

    private static void setANotifySchedule(WorkManager workManager) {
        // Event 발생시 WorkerA.class 호출
        // 알림 활성화 시점에서 반복 주기 이전에 있는 가장 빠른 알림 생성
        OneTimeWorkRequest aWorkerOneTimePushRequest = new OneTimeWorkRequest.Builder(WorkerA.class).build();
        // 가장 가까운 알림시각까지 대기 후 실행, 12시간 간격 반복 5분 이내 완료
        PeriodicWorkRequest aWorkerPeriodicPushRequest =
                new PeriodicWorkRequest.Builder(WorkerA.class, 12, TimeUnit.HOURS, 5, TimeUnit.MINUTES)
                        .build();
        try {
            // workerA 정보 조회
            List<WorkInfo> aWorkerNotifyWorkInfoList = workManager.getWorkInfosForUniqueWorkLiveData(WORK_A_NAME).getValue();
            for (WorkInfo workInfo : aWorkerNotifyWorkInfoList) {
                // worker의 동작이 종료된 상태라면 worker 재등록
                if (workInfo.getState().isFinished()) {
                    workManager.enqueue(aWorkerOneTimePushRequest);
                    workManager.enqueueUniquePeriodicWork(WORK_A_NAME, ExistingPeriodicWorkPolicy.KEEP, aWorkerPeriodicPushRequest);
                }
            }
        } catch (NullPointerException nullPointerException) {
            // 알림 worker가 생성된 적이 없으면 worker 생성
            workManager.enqueue(aWorkerOneTimePushRequest);
            workManager.enqueueUniquePeriodicWork(WORK_A_NAME, ExistingPeriodicWorkPolicy.KEEP, aWorkerPeriodicPushRequest);
        }
    }

    private static void setBNotifySchedule(WorkManager workManager) {
        // Event 발생 시 WorkerB.class 호출
        OneTimeWorkRequest bWorkerOneTimePushRequest = new OneTimeWorkRequest.Builder(WorkerB.class).build();
        PeriodicWorkRequest bWorkerPeriodicPushRequest =
                new PeriodicWorkRequest.Builder(WorkerB.class, 12, TimeUnit.HOURS, 5, TimeUnit.MINUTES)
                        .build();
        try {
            List<WorkInfo> bWorkerNotifyWorkInfoList = workManager.getWorkInfosForUniqueWorkLiveData(WORK_B_NAME).getValue();
            for (WorkInfo workInfo : bWorkerNotifyWorkInfoList) {
                if (workInfo.getState().isFinished()) {
                    workManager.enqueue(bWorkerOneTimePushRequest);
                    workManager.enqueueUniquePeriodicWork(WORK_B_NAME, ExistingPeriodicWorkPolicy.KEEP, bWorkerPeriodicPushRequest);
                }
            }
        } catch (NullPointerException nullPointerException) {
            workManager.enqueue(bWorkerOneTimePushRequest);
            workManager.enqueueUniquePeriodicWork(WORK_B_NAME, ExistingPeriodicWorkPolicy.KEEP, bWorkerPeriodicPushRequest);
        }
    }

백그라운드 등록하는 메소드를 모두 작성했으니 푸시알림 옵션을 켰을 때 등록하게끔 해야한다. 다시 SecondActivity로 가서 알림 활성화를 켜면 등록하고 해제하면 제거하도록 하자.

SecondActivity - 백그라운드 옵션에 따라 생성/삭제

    private void initSwitchLayout(final WorkManager workManager) {
        switchActivateNotify = (CompoundButton) findViewById(R.id.switch_second_notify);
        switchActivateNotify.setChecked(PreferenceHelper.getBoolean(getApplicationContext(), Constants.SHARED_PREF_NOTIFICATION_KEY));
        switchActivateNotify.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() {
            @Override
            public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) {
                if (isChecked) {
                    boolean isChannelCreated = NotificationHelper.isNotificationChannelCreated(getApplicationContext());
                    if (isChannelCreated) {
                        PreferenceHelper.setBoolean(getApplicationContext(), Constants.SHARED_PREF_NOTIFICATION_KEY, true);
                        NotificationHelper.setScheduledNotification(workManager);
                    } else {
                        NotificationHelper.createNotificationChannel(getApplicationContext());
                    }
                } else {
                    PreferenceHelper.setBoolean(getApplicationContext(), Constants.SHARED_PREF_NOTIFICATION_KEY, false);
                    workManager.cancelAllWork();
                }
            }
        });
    }

푸시알림 백그라운드 갱신

푸시알림 옵션을 활성화했음에도 doze mode나 기기 종료 혹은 예기치 못한 에러 등 다양한 변수에 의해 종료될 수 있다. Observer를 붙여서 실시간으로 모니터링 할 수도 있겠지만 대부분의 알림은 그렇게까지 민감하고 긴급한 정보를 담고있지 않으므로 앱을 시작할 때 Initapplication에서 refresh하게끔 구현하였다. 그리고 사용자에 의해 알림이 비활성화된 경우(비행기 모드나 알림 차단)도 있으므로 허용 및 가능 여부를 체크하여 다시 등록한다.

NotificationHelper.class - 푸시알림 백그라운드 refresh

    // 푸시 알림 허용 및 사용자에 의해 알림이 꺼진 상태가 아니라면 푸시 알림 백그라운드 갱신
    public static void refreshScheduledNotification(Context context) {
        try {
            Boolean isNotificationActivated = PreferenceHelper.getBoolean(context, Constants.SHARED_PREF_NOTIFICATION_KEY);
            if (isNotificationActivated) {
                NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
                boolean isNotifyAllowed;
                if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
                    int channelImportance = notificationManager.getNotificationChannel(Constants.NOTIFICATION_CHANNEL_ID).getImportance();
                    isNotifyAllowed = channelImportance != NotificationManager.IMPORTANCE_NONE;
                } else {
                    isNotifyAllowed = NotificationManagerCompat.from(context).areNotificationsEnabled();
                }
                if (isNotifyAllowed) {
                    NotificationHelper.setScheduledNotification(WorkManager.getInstance(context));
                }
            }
        } catch (NullPointerException nullException) {
            Toast.makeText(context, "푸시 알림 기능에 문제가 발생했습니다. 앱을 재실행해주세요.", Toast.LENGTH_SHORT).show();
            nullException.printStackTrace();
        }
    }

InitApplication - 시작 시 동작 코드

public class InitApplication extends Application {
    private Context mContext;
    @Override
    public void onCreate() {
        super.onCreate();
        NotificationHelper.createNotificationChannel(getApplicationContext());
        NotificationHelper.refreshScheduledNotification(getApplicationContext());
    }
}

Background Worker 준비

백그라운드 등록할 떄 Event 발생 시 특정 class를 호출하도록 하였다. 이는 등록된 백그라운드 작업에서 정의했던 조건을 충족하여 이후 실행할 동작과의 연결을 의미한다. 각 A, B 백그라운드 동작에서 정해둔 호출 규칙을 충축하면 WorkerA.class, WorkerB.class를 호출하도록 정의해준다.

WorkerA.class - 호출 시 동작

    @NonNull
    @Override
    public Result doWork() {
        NotificationHelper mNotificationHelper = new NotificationHelper(getApplicationContext());
        long currentMillis = Calendar.getInstance(TimeZone.getTimeZone(KOREA_TIMEZONE), Locale.KOREA).getTimeInMillis();

        // 알림 범위(08:00-09:00, 20:00-21:00)에 해당하는지 기준 설정
        Calendar eventCal = NotificationHelper.getScheduledCalender(A_MORNING_EVENT_TIME);
        long morningNotifyMinRange = eventCal.getTimeInMillis();

        eventCal.add(Calendar.HOUR_OF_DAY, Constants.NOTIFICATION_INTERVAL_HOUR);
        long morningNotifyMaxRange = eventCal.getTimeInMillis();

        eventCal.set(Calendar.HOUR_OF_DAY, A_NIGHT_EVENT_TIME);
        long nightNotifyMinRange = eventCal.getTimeInMillis();

        eventCal.add(Calendar.HOUR_OF_DAY, Constants.NOTIFICATION_INTERVAL_HOUR);
        long nightNotifyMaxRange = eventCal.getTimeInMillis();

        // 현재 시각이 오전 알림 범위에 해당하는지
        boolean isMorningNotifyRange = morningNotifyMinRange <= currentMillis && currentMillis <= morningNotifyMaxRange;
        // 현재 시각이 오후 알림 범위에 해당하는지
        boolean isNightNotifyRange = nightNotifyMinRange <= currentMillis && currentMillis <= nightNotifyMaxRange;
        // 현재 시각이 알림 범위에 해당여부
        boolean isEventANotifyAvailable = isMorningNotifyRange || isNightNotifyRange;


        if (isEventANotifyAvailable) {
            // 현재 시각이 알림 범위에 해당하면 알림 생성
            mNotificationHelper.createNotification(Constants.WORK_A_NAME);
        } else {
            // 그 외의 경우 가장 빠른 A 이벤트 예정 시각까지의 notificationDelay 계산하여 딜레이 호출
            long notificationDelay = NotificationHelper.getNotificationDelay(Constants.WORK_A_NAME);
            OneTimeWorkRequest workRequest =
                    new OneTimeWorkRequest.Builder(WorkerB.class)
                            .setInitialDelay(notificationDelay, TimeUnit.MILLISECONDS)
                            .build();
            WorkManager.getInstance(getApplicationContext()).enqueue(workRequest);
        }
        return Result.success();
    }

NotificationHelper.class - 딜레이 계산

    // 현재시각이 알림 범위에 해당하지 않으면 딜레이 리턴
    public static long getNotificationDelay(String workName) {
        long pushDelayMillis = 0;
        Calendar cal = Calendar.getInstance(TimeZone.getTimeZone(KOREA_TIMEZONE), Locale.KOREA);
        long currentMillis = cal.getTimeInMillis();
        if (workName.equals(WORK_A_NAME)) {
            // 현재 시각이 20:00보다 크면 다음 날 오전 알림, 현재 시각이 20:00 전인지 08:00 전인지에 따라 알림 딜레이 설정
            if (cal.get(Calendar.HOUR_OF_DAY) >= Constants.A_NIGHT_EVENT_TIME) {
                Calendar nextDayCal = getScheduledCalender(A_MORNING_EVENT_TIME);
                nextDayCal.add(Calendar.DAY_OF_YEAR, 1);
                pushDelayMillis = nextDayCal.getTimeInMillis() - currentMillis;

            } else if (cal.get(Calendar.HOUR_OF_DAY) >= A_MORNING_EVENT_TIME && cal.get(Calendar.HOUR_OF_DAY) < A_NIGHT_EVENT_TIME) {
                pushDelayMillis = getScheduledCalender(A_NIGHT_EVENT_TIME).getTimeInMillis() - currentMillis;

            } else if (cal.get(cal.get(Calendar.HOUR_OF_DAY)) < A_MORNING_EVENT_TIME) {
                pushDelayMillis = getScheduledCalender(A_MORNING_EVENT_TIME).getTimeInMillis() - currentMillis;
            }
        } else if (workName.equals(WORK_B_NAME)) {
            // 현재 시각이 21:00보다 크면 다음 날 오전 알림, 현재 시각이 21:00 전인지 09:00 전인지에 따라 알림 딜레이 설정
            if (cal.get(Calendar.HOUR_OF_DAY) >= B_NIGHT_EVENT_TIME) {
                Calendar nextDayCal = getScheduledCalender(B_MORNING_EVENT_TIME);
                nextDayCal.add(Calendar.DAY_OF_YEAR, 1);
                pushDelayMillis = nextDayCal.getTimeInMillis() - currentMillis;

            } else if (cal.get(Calendar.HOUR_OF_DAY) >= B_MORNING_EVENT_TIME && cal.get(Calendar.HOUR_OF_DAY) < B_NIGHT_EVENT_TIME) {
                pushDelayMillis = getScheduledCalender(B_NIGHT_EVENT_TIME).getTimeInMillis() - currentMillis;

            } else if (cal.get(cal.get(Calendar.HOUR_OF_DAY)) < B_MORNING_EVENT_TIME) {
                pushDelayMillis = getScheduledCalender(B_MORNING_EVENT_TIME).getTimeInMillis() - currentMillis;
            }
        }
        return pushDelayMillis;
    }

NotificationHelper.class - Calender 반환

    // 한번 실행 시 이후 재호출해도 동작 안함
    public static void createNotificationChannel(Context context) {
        try {
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
                NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
                // NotificationChannel 초기화
                NotificationChannel notificationChannel = new NotificationChannel(NOTIFICATION_CHANNEL_ID, context.getString(R.string.app_name), NotificationManager.IMPORTANCE_DEFAULT);

                // Configure the notification channel
                notificationChannel.setDescription("푸시알림");
                notificationChannel.enableLights(true); // 화면활성화 설정
                notificationChannel.setVibrationPattern(new long[]{0, 1000, 500}); // 진동패턴 설정
                notificationChannel.enableVibration(true); // 진동 설정
                notificationManager.createNotificationChannel(notificationChannel); // channel 생성
            }
        } catch (NullPointerException nullException) {
            // notificationManager null 오류 raise
            Toast.makeText(context, "푸시 알림 채널 생성에 실패했습니다. 앱을 재실행하거나 재설치해주세요.", Toast.LENGTH_SHORT).show();
            nullException.printStackTrace();
        }
    }

enqueueWorkRequest로 호출된 Worker 클래스는 doWork()에 정의한 대로 동작한다. A에서는 알림 타임이 오전/오후 8시이고 알림 Interval을 1시간으로 설정하여 오전/오후 8시~9시에 호출되었을 경우 알림을 생성하고 그렇지 않으면 호출된 시각으로부터 가장 빠른 이벤트 예정 시각까지의 시간을 계산한 뒤 일회성 요청에 delay를 적용한 뒤 다시 대기를 한다. 이 때는 OneTimeWorkRequest로 등록하여 다시 동작 중인 PeriodicWorkRequest에 영향이 가지 않도록 한다. 정해진 시각이 등록된 Calender 객체를 생성한뒤 딜레이를 계산하는 메소드를 정의하였다. 오후 이벤트보다 현재 시각이 늦었으면 내일 오전 이벤트 시각까지의 시간을, 그렇지 않으면 각각 범위에 따라 오전/ 오후 이벤트까지의 시간을 반환한다.

전체 코드

원래 이전처럼 포스팅에 UI Layout부터 각 class까지 쭉 나열하려 했지만 너무 길어지는 바람에 깃헙 링크만 첨부하기로 했습니다... 설명의 간소화를 위해 중복파트를 누락시키거나 불필요한 코드를 자른 부분이 있기 때문에 전체 코드를 보려면 깃헙 링크를 참고 부탁드립니다.

전체 프로젝트 코드 깃헙링크 - MyNotification

참고자료

+ Recent posts