From cb33935a4ed3a1efc005d5d4fdabcb86c3b5d8fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Sun, 6 Sep 2026 15:45:44 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=EC=9E=A5=EB=B6=80=EA=B0=80=20=EB=B9=88?= =?UTF-8?q?=20=EA=B8=B0=EA=B8=B0=EC=97=90=EC=84=9C=EB=8F=84=20=EC=95=8C?= =?UTF-8?q?=EB=9E=8C=EC=9D=84=20=EC=84=B8=EC=9A=B0=EA=B3=A0,=20=EB=AF=B8?= =?UTF-8?q?=EB=A4=84=20=EB=91=94=20=EC=95=8C=EB=9E=8C=EC=9D=84=20=EC=9E=83?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EB=8A=94=EB=8B=A4=20(176,=20177,=20179)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 알람 장부는 기기 안에만 있고 백업·기기 이전에서 일부러 뺐다(#127). 그 결정의 나머지 반쪽이 없었다. - 폰을 바꾸거나 앱을 다시 깔면 장부가 빈 채로 시작해, 서버에 일정이 그대로 있어도 알람은 하나도 걸리지 않았다. 화면에는 오늘 일정이 다 보이므로 사용자에게는 「일정은 보이는데 안 울린다」 로만 드러나고, 고칠 길은 일정을 하나하나 다시 저장하는 것뿐인데 그걸 알 방법이 없다. 로그인 뒤 서버 목록으로 한 번 맞춘다(#176). 내용이 그대로인 일정은 걸린 회차를 지킨다 — 매번 다시 걸면 앱을 열 때마다 방금 미뤄 둔 알람이 사라진다. 서버에서 사라진 일정의 알람은 지운다. - 다시 알림은 AlarmScheduler 를 거치지 않고 걸려 장부에 아무것도 남지 않았다. 재부팅과 앱 교체로 조용히 사라진다. 미룬 사람은 5분 뒤 울릴 것이라고 믿고 있고 그 믿음이 이 기능의 전부다. 예약과 기록을 한 자리에 묶는다(#177). - 알람 자리를 정하는 값을 AlarmSlot 으로 끄집어내고 예약·취소가 모두 그 값을 지나게 한다. 지워도 초록이던 테스트 두 개를, 다시 알림 자리가 어떤 일정의 알람 자리와도 겹치지 않는다는 실제 단언으로 바꾼다(#179). --- .../main/res/xml/data_extraction_rules.xml | 13 +- .../slowclock/core/alarm/AlarmScheduler.kt | 113 ++++++++++++++++++ .../core/alarm/AlarmSchedulerEntryPoint.kt | 27 +++++ .../core/alarm/ScheduleAlarmHelper.kt | 57 +++++++-- .../slowclock/receiver/AlarmReceiver.kt | 34 ++---- .../receiver/AlarmRestoreReceiver.kt | 21 +--- .../slowclock/ui/alarm/AlarmTriggerService.kt | 48 +++++++- .../core/alarm/ScheduleAlarmHelperTest.kt | 55 ++++++--- .../remote/repository/ScheduleRepository.kt | 21 ++++ .../repository/ScheduledAlarmRepository.kt | 75 ++++++++++++ .../remote/repository/ScheduledAlarmTest.kt | 22 ++++ .../slowclock/ui/main/MainViewModel.kt | 28 +++++ .../slowclock/ui/main/MainViewModelTest.kt | 25 ++++ 13 files changed, 464 insertions(+), 75 deletions(-) create mode 100644 core/alarm/src/main/java/com/example/slowclock/core/alarm/AlarmSchedulerEntryPoint.kt diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml index bacba909..c9974e6f 100644 --- a/app/src/main/res/xml/data_extraction_rules.xml +++ b/app/src/main/res/xml/data_extraction_rules.xml @@ -1,18 +1,27 @@ + + diff --git a/core/alarm/src/main/java/com/example/slowclock/core/alarm/AlarmScheduler.kt b/core/alarm/src/main/java/com/example/slowclock/core/alarm/AlarmScheduler.kt index 1cabc5e4..2a6ccd8e 100644 --- a/core/alarm/src/main/java/com/example/slowclock/core/alarm/AlarmScheduler.kt +++ b/core/alarm/src/main/java/com/example/slowclock/core/alarm/AlarmScheduler.kt @@ -5,6 +5,8 @@ import android.content.Context import com.example.slowclock.data.model.Schedule import com.example.slowclock.data.remote.repository.ScheduledAlarm import com.example.slowclock.data.remote.repository.ScheduledAlarmRepository +import com.example.slowclock.data.remote.repository.SnoozedAlarm +import com.example.slowclock.data.remote.repository.SnoozedAlarmRepository import com.example.slowclock.util.Recurrence import com.google.firebase.Timestamp import dagger.hilt.android.qualifiers.ApplicationContext @@ -26,6 +28,7 @@ class AlarmScheduler constructor( @ApplicationContext private val context: Context, private val scheduledAlarms: ScheduledAlarmRepository, + private val snoozedAlarms: SnoozedAlarmRepository, ) { /** * 일정의 다음 회차 알람을 건다. @@ -41,6 +44,86 @@ class AlarmScheduler fun cancel(schedule: Schedule) { ScheduleAlarmHelper.cancelAlarm(context, schedule) scheduledAlarms.remove(schedule.id) + forgetSnoozesOf(schedule.id) + } + + /** + * 서버에 있는 일정으로 이 기기의 알람을 맞춘다. + * + * 장부는 기기 안에만 있고 백업·기기 이전에서 일부러 뺐다. 그래서 폰을 바꾸거나 앱을 다시 + * 깔면 장부가 빈 채로 시작하고, 서버에 일정이 그대로 있어도 알람은 하나도 걸리지 않는다. + * 사용자에게는 「일정은 보이는데 안 울린다」 로만 보인다(#176). + * + * 내용이 그대로인 일정은 걸린 회차를 지킨다. 앱을 열 때마다 다시 걸면 방금 미뤄 둔 + * 알람이 그때마다 사라진다. + */ + fun syncWith(schedules: List) { + val nowMillis = System.currentTimeMillis() + val incoming = schedules.associateBy { it.id } + val booked = scheduledAlarms.all() + // 서버에서 사라진 일정의 알람은 지운다. 다른 기기에서 지운 일정이 이 기기에서만 + // 계속 울리는 것도 이 자리에서 함께 막힌다. + booked.filterNot { incoming.containsKey(it.id) }.forEach { stale -> + ScheduleAlarmHelper.cancelAlarm(context, stale.toSchedule()) + scheduledAlarms.remove(stale.id) + forgetSnoozesOf(stale.id) + } + val bookedById = booked.associateBy { it.id } + incoming.values.forEach { schedule -> + val fresh = schedule.toRecord() + val existing = bookedById[schedule.id] + // 장부에도 없고 걸 것도 남지 않은 일정은 손댈 것이 없다. 지난 일정을 앱을 열 + // 때마다 훑어 취소하지 않는다. + if (existing == null && !fresh.isLive(nowMillis)) return@forEach + book(if (existing != null && existing.sameContentAs(fresh)) existing else fresh, nowMillis) + } + } + + /** + * 알람 하나를 미룬다. 걸어 둔 사실을 기기에 남긴다. + * + * 다시 알림은 AlarmManager 에만 걸려 있어 재부팅과 앱 교체로 사라진다. 미룬 사람은 5분 + * 뒤 울릴 것이라고 믿고 있고, 그 믿음이 이 기능의 전부다(#177). + */ + fun snooze( + baseRequestCode: Int, + scheduleId: String, + title: String, + desc: String, + isFullScreen: Boolean, + snoozeCount: Int, + ) { + val triggerAt = SnoozePolicy.nextTriggerAt(System.currentTimeMillis()) + ScheduleAlarmHelper.scheduleSnooze( + context = context, + baseRequestCode = baseRequestCode, + scheduleId = scheduleId, + title = title, + desc = desc, + isFullScreen = isFullScreen, + snoozeCount = snoozeCount, + triggerTime = triggerAt, + ) + snoozedAlarms.save( + SnoozedAlarm( + baseRequestCode = baseRequestCode, + scheduleId = scheduleId, + title = title, + description = desc, + isFullScreen = isFullScreen, + snoozeCount = snoozeCount, + triggerAtMillis = triggerAt, + ), + ) + } + + /** 미뤄 둔 알람이 울렸다. 기록을 지운다. 남기면 다음 부팅에 한 번 더 울린다. */ + fun snoozeFired(baseRequestCode: Int) { + snoozedAlarms.remove(baseRequestCode) + } + + private fun forgetSnoozesOf(scheduleId: String) { + snoozedAlarms.all().filter { it.scheduleId == scheduleId }.forEach { snoozedAlarms.remove(it.baseRequestCode) } } /** @@ -86,6 +169,9 @@ class AlarmScheduler fun cancelAll() { scheduledAlarms.all().forEach { ScheduleAlarmHelper.cancelAlarm(context, it.toSchedule()) } scheduledAlarms.clear() + // 미뤄 둔 알람은 일정 알람과 자리 번호가 같아 위 취소로 함께 지워진다. 장부만 남으면 + // 다음 부팅에 근거 없는 알람이 되살아나므로 여기서 함께 비운다(#177). + snoozedAlarms.clear() } /** 알람을 거는 쪽이 보는 값. 장부의 기록을 [occurrenceStartMillis] 회차로 옮겨 놓은 일정이다. */ @@ -111,6 +197,33 @@ class AlarmScheduler // 재부팅으로 걸린 것이 전부 사라졌으므로, 지키던 회차라도 다시 걸어야 한다. book(record.copy(bookedStartMillis = null), nowMillis) } + restoreSnoozes(nowMillis) + } + + /** + * 미뤄 둔 알람을 다시 건다. 이미 지난 것은 버린다. + * + * 일정 알람보다 뒤에 건다. 자리 번호가 같아 순서가 바뀌면 방금 되살린 다시 알림을 + * 일정 알람 쪽 취소가 지운다(#177). + */ + private fun restoreSnoozes(nowMillis: Long) { + snoozedAlarms.all().forEach { snoozed -> + if (snoozed.triggerAtMillis <= nowMillis) { + // 부팅하자마자 지난 알람이 울리는 것은 도움이 아니라 오작동이다. + snoozedAlarms.remove(snoozed.baseRequestCode) + return@forEach + } + ScheduleAlarmHelper.scheduleSnooze( + context = context, + baseRequestCode = snoozed.baseRequestCode, + scheduleId = snoozed.scheduleId, + title = snoozed.title, + desc = snoozed.description, + isFullScreen = snoozed.isFullScreen, + snoozeCount = snoozed.snoozeCount, + triggerTime = snoozed.triggerAtMillis, + ) + } } /** diff --git a/core/alarm/src/main/java/com/example/slowclock/core/alarm/AlarmSchedulerEntryPoint.kt b/core/alarm/src/main/java/com/example/slowclock/core/alarm/AlarmSchedulerEntryPoint.kt new file mode 100644 index 00000000..4418af34 --- /dev/null +++ b/core/alarm/src/main/java/com/example/slowclock/core/alarm/AlarmSchedulerEntryPoint.kt @@ -0,0 +1,27 @@ +package com.example.slowclock.core.alarm + +import android.content.Context +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.android.EntryPointAccessors +import dagger.hilt.components.SingletonComponent + +/** + * Hilt 가 못 넣어 주는 자리에서 [AlarmScheduler] 를 꺼내는 통로. + * + * `@AndroidEntryPoint` 는 Kotlin BroadcastReceiver 에서 `super.onReceive` 를 부를 수 없어 쓰지 + * 않는다. 서비스도 같은 통로를 쓰게 모아 둔다 — 예약과 장부를 한 자리에 묶어 두는 것이 + * [AlarmScheduler] 의 몫이라, 이 통로를 안 지나면 장부가 샌다(#127 · #177). + */ +@EntryPoint +@InstallIn(SingletonComponent::class) +interface AlarmSchedulerEntryPoint { + fun alarmScheduler(): AlarmScheduler + + companion object { + fun from(context: Context): AlarmScheduler = + EntryPointAccessors + .fromApplication(context.applicationContext, AlarmSchedulerEntryPoint::class.java) + .alarmScheduler() + } +} diff --git a/core/alarm/src/main/java/com/example/slowclock/core/alarm/ScheduleAlarmHelper.kt b/core/alarm/src/main/java/com/example/slowclock/core/alarm/ScheduleAlarmHelper.kt index 8b88801e..6dbc1af0 100644 --- a/core/alarm/src/main/java/com/example/slowclock/core/alarm/ScheduleAlarmHelper.kt +++ b/core/alarm/src/main/java/com/example/slowclock/core/alarm/ScheduleAlarmHelper.kt @@ -18,6 +18,21 @@ internal enum class AlarmKind( END("종료"), } +/** + * 알람 하나가 차지하는 자리. + * + * `PendingIntent` 가 같은 자리인지는 [requestCode] 와 `Intent.filterEquals` 로 정해진다. + * filterEquals 는 action 과 component 를 보고 extras 는 보지 않는데, 이 앱에서 component 는 + * 언제나 `AlarmReceiver` 라 실제로 자리를 가르는 값은 이 둘뿐이다. + * + * 예약과 취소가 반드시 같은 자리를 가리켜야 해서 두 쪽 모두 이 값에서 시작한다. 한쪽만 고치면 + * 취소가 빗나가 지워졌어야 할 알람이 남고, 화면에는 아무 표시도 나지 않는다(#117 · #129 · #179). + */ +internal data class AlarmSlot( + val requestCode: Int, + val action: String?, +) + /** * AlarmManager 를 직접 만지는 유일한 자리. 모듈 밖에서는 [AlarmScheduler] 만 보인다. * @@ -103,11 +118,12 @@ internal object ScheduleAlarmHelper { return } - val requestCode = requestCodeOf(schedule.id, kind) + val slot = slotOf(schedule.id, kind) val intent = Intent(context, AlarmReceiver::class.java) // 대상을 한 번 더 못박는다. 중복이 아니라 정적 분석을 위한 것이다(#121). .setClass(context, AlarmReceiver::class.java) + .setAction(slot.action) .apply { putExtra(EXTRA_TITLE, "${schedule.title} (${kind.label})") putExtra(EXTRA_DESC, schedule.description) @@ -115,12 +131,12 @@ internal object ScheduleAlarmHelper { putExtra(EXTRA_SCHEDULE_ID, schedule.id) putExtra(EXTRA_ALARM_TYPE, kind.label) // 이 알람이 어느 자리 것인지 받는 쪽이 알아야 다시 알림을 같은 자리에 건다(#129). - putExtra(EXTRA_REQUEST_CODE, requestCode) + putExtra(EXTRA_REQUEST_CODE, slot.requestCode) } val pendingIntent = PendingIntent.getBroadcast( context, - requestCode, + slot.requestCode, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, ) @@ -138,7 +154,7 @@ internal object ScheduleAlarmHelper { // 정시 허용이 없을 때의 차선. 몇 분 늦을 수 있지만 절전 상태에서도 울린다. alarmManager.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerTime, pendingIntent) } - Log.d(TAG, "${kind.label} 알람 예약: ${schedule.title} at ${Date(triggerTime)} (requestCode=$requestCode)") + Log.d(TAG, "${kind.label} 알람 예약: ${schedule.title} at ${Date(triggerTime)} (requestCode=${slot.requestCode})") } catch (e: Exception) { Log.e(TAG, "${kind.label} 알람 예약 실패: ${e.message}") } @@ -175,13 +191,14 @@ internal object ScheduleAlarmHelper { desc: String, isFullScreen: Boolean, snoozeCount: Int, + triggerTime: Long, ) { val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager - val triggerTime = SnoozePolicy.nextTriggerAt(System.currentTimeMillis()) + val slot = snoozeSlotOf(baseRequestCode) val intent = Intent(context, AlarmReceiver::class.java) .setClass(context, AlarmReceiver::class.java) - .setAction(ACTION_SNOOZE_ALARM) + .setAction(slot.action) .apply { putExtra(EXTRA_TITLE, title) putExtra(EXTRA_DESC, desc) @@ -194,7 +211,7 @@ internal object ScheduleAlarmHelper { val pendingIntent = PendingIntent.getBroadcast( context, - baseRequestCode, + slot.requestCode, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, ) @@ -226,14 +243,15 @@ internal object ScheduleAlarmHelper { baseRequestCode: Int, ) { try { + val slot = snoozeSlotOf(baseRequestCode) val intent = Intent(context, AlarmReceiver::class.java) .setClass(context, AlarmReceiver::class.java) - .setAction(ACTION_SNOOZE_ALARM) + .setAction(slot.action) val pendingIntent = PendingIntent.getBroadcast( context, - baseRequestCode, + slot.requestCode, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, ) @@ -256,12 +274,13 @@ internal object ScheduleAlarmHelper { schedule: Schedule, kind: AlarmKind, ) { - val requestCode = requestCodeOf(schedule.id, kind) + val slot = slotOf(schedule.id, kind) try { val intent = Intent(context, AlarmReceiver::class.java) // 예약 쪽과 같은 이유로 대상을 다시 지정한다(#121). .setClass(context, AlarmReceiver::class.java) + .setAction(slot.action) .apply { putExtra(EXTRA_TITLE, "${schedule.title} (${kind.label})") putExtra(EXTRA_DESC, schedule.description) @@ -271,14 +290,14 @@ internal object ScheduleAlarmHelper { val pendingIntent = PendingIntent.getBroadcast( context, - requestCode, + slot.requestCode, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, ) alarmManager.cancel(pendingIntent) pendingIntent.cancel() - Log.d(TAG, "${kind.label} 알람 취소: ${schedule.title} (requestCode=$requestCode)") + Log.d(TAG, "${kind.label} 알람 취소: ${schedule.title} (requestCode=${slot.requestCode})") } catch (e: Exception) { Log.e(TAG, "${kind.label} 알람 취소 실패: ${e.message}") } @@ -300,6 +319,20 @@ internal object ScheduleAlarmHelper { ) } + /** 일정 알람의 자리. action 이 없다 — 이 앱에서 action 을 쓰는 알람은 다시 알림뿐이다. */ + internal fun slotOf( + scheduleId: String, + kind: AlarmKind, + ): AlarmSlot = AlarmSlot(requestCodeOf(scheduleId, kind), null) + + /** + * 다시 알림의 자리. 원래 알람과 번호가 같고 action 으로만 갈린다. + * + * 일정 알람의 번호는 짝수 전체와 홀수 전체, 곧 32비트 정수 전체를 덮으므로 「비어 있는 + * 번호 대역」 이 없다. 그래서 번호로는 가를 수 없다(#129). + */ + internal fun snoozeSlotOf(baseRequestCode: Int): AlarmSlot = AlarmSlot(baseRequestCode, ACTION_SNOOZE_ALARM) + internal fun requestCodeOf( scheduleId: String, kind: AlarmKind, diff --git a/core/alarm/src/main/java/com/example/slowclock/receiver/AlarmReceiver.kt b/core/alarm/src/main/java/com/example/slowclock/receiver/AlarmReceiver.kt index bdcb2381..0385e466 100644 --- a/core/alarm/src/main/java/com/example/slowclock/receiver/AlarmReceiver.kt +++ b/core/alarm/src/main/java/com/example/slowclock/receiver/AlarmReceiver.kt @@ -10,14 +10,10 @@ import android.media.RingtoneManager import android.util.Log import androidx.core.app.NotificationCompat import androidx.core.content.ContextCompat -import com.example.slowclock.core.alarm.AlarmScheduler +import com.example.slowclock.core.alarm.AlarmSchedulerEntryPoint import com.example.slowclock.core.alarm.R import com.example.slowclock.core.alarm.ScheduleAlarmHelper import com.example.slowclock.ui.alarm.AlarmTriggerService -import dagger.hilt.EntryPoint -import dagger.hilt.InstallIn -import dagger.hilt.android.EntryPointAccessors -import dagger.hilt.components.SingletonComponent /** * 예약된 시각에 시스템이 부르는 자리. 실제로 울리는 일은 [AlarmTriggerService] 가 한다. @@ -26,16 +22,6 @@ import dagger.hilt.components.SingletonComponent * 깨운 직후에는 백그라운드에서도 포그라운드 서비스를 시작할 수 있다. */ class AlarmReceiver : BroadcastReceiver() { - /** - * 다음 회차를 걸려면 스케줄러가 필요하다. `@AndroidEntryPoint` 는 Kotlin 에서 - * `super.onReceive` 를 부를 수 없어 쓰지 않는다([AlarmRestoreReceiver] 와 같은 이유). - */ - @EntryPoint - @InstallIn(SingletonComponent::class) - interface AlarmSchedulerEntryPoint { - fun alarmScheduler(): AlarmScheduler - } - private companion object { const val TAG = "AlarmReceiver" @@ -64,14 +50,16 @@ class AlarmReceiver : BroadcastReceiver() { // 되풀이하는 일정이면 다음 회차를 여기서 이어 건다. 한 번에 하나만 걸어 두므로 // 이 자리에서 걸지 않으면 반복이 한 번으로 끝난다(#130). - if (scheduleId.isNotBlank()) { - runCatching { - EntryPointAccessors - .fromApplication(context.applicationContext, AlarmSchedulerEntryPoint::class.java) - .alarmScheduler() - .scheduleNextOccurrence(scheduleId) - }.onFailure { Log.e(TAG, "다음 회차 예약 실패", it) } - } + // + // 미뤄 둔 알람이 울린 것이면 그 기록도 지운다. 남기면 다음 부팅에 근거 없이 한 번 + // 더 울린다(#177). + runCatching { + val scheduler = AlarmSchedulerEntryPoint.from(context) + if (intent.action == ScheduleAlarmHelper.ACTION_SNOOZE_ALARM) { + scheduler.snoozeFired(requestCode) + } + if (scheduleId.isNotBlank()) scheduler.scheduleNextOccurrence(scheduleId) + }.onFailure { Log.e(TAG, "알람 뒷정리 실패", it) } try { ContextCompat.startForegroundService( diff --git a/core/alarm/src/main/java/com/example/slowclock/receiver/AlarmRestoreReceiver.kt b/core/alarm/src/main/java/com/example/slowclock/receiver/AlarmRestoreReceiver.kt index b5df83ea..806144eb 100644 --- a/core/alarm/src/main/java/com/example/slowclock/receiver/AlarmRestoreReceiver.kt +++ b/core/alarm/src/main/java/com/example/slowclock/receiver/AlarmRestoreReceiver.kt @@ -4,11 +4,7 @@ import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.util.Log -import com.example.slowclock.core.alarm.AlarmScheduler -import dagger.hilt.EntryPoint -import dagger.hilt.InstallIn -import dagger.hilt.android.EntryPointAccessors -import dagger.hilt.components.SingletonComponent +import com.example.slowclock.core.alarm.AlarmSchedulerEntryPoint /** * 걸어 둔 알람을 다시 거는 자리. @@ -29,7 +25,7 @@ import dagger.hilt.components.SingletonComponent * 한 줄도 없어 다시 계산할 것이 없다. 등록해 두면 뒤에 읽는 사람이 「시각이 현지시각이구나」 로 * 오해한다. * - * 의존성을 `@AndroidEntryPoint` + 필드 주입이 아니라 [EntryPointAccessors] 로 받는다. Kotlin 은 + * 의존성을 `@AndroidEntryPoint` + 필드 주입이 아니라 [AlarmSchedulerEntryPoint] 로 받는다. Kotlin 은 * `BroadcastReceiver.onReceive` 가 추상이라 Hilt 가 요구하는 `super.onReceive` 를 부를 수 없고, * 그 호출을 빼면 주입이 되는지 여부가 부팅 때만 드러난다. 여기서는 받는 자리를 눈으로 볼 수 있게 둔다. * @@ -38,12 +34,6 @@ import dagger.hilt.components.SingletonComponent * 호출을 들이지 않는 것이 이 파일의 계약이다. */ class AlarmRestoreReceiver : BroadcastReceiver() { - @EntryPoint - @InstallIn(SingletonComponent::class) - interface AlarmSchedulerEntryPoint { - fun alarmScheduler(): AlarmScheduler - } - override fun onReceive( context: Context, intent: Intent, @@ -58,12 +48,7 @@ class AlarmRestoreReceiver : BroadcastReceiver() { } Log.d(TAG, "알람 복원: ${intent.action}") - runCatching { - EntryPointAccessors - .fromApplication(context.applicationContext, AlarmSchedulerEntryPoint::class.java) - .alarmScheduler() - .restoreAll() - }.onFailure { + runCatching { AlarmSchedulerEntryPoint.from(context).restoreAll() }.onFailure { // 복원에 실패해도 부팅 방송을 물고 죽지 않는다. 앱을 열면 그때 다시 걸린다. Log.e(TAG, "알람 복원 실패", it) } diff --git a/core/alarm/src/main/java/com/example/slowclock/ui/alarm/AlarmTriggerService.kt b/core/alarm/src/main/java/com/example/slowclock/ui/alarm/AlarmTriggerService.kt index 5ed9e2c2..a974b8cd 100644 --- a/core/alarm/src/main/java/com/example/slowclock/ui/alarm/AlarmTriggerService.kt +++ b/core/alarm/src/main/java/com/example/slowclock/ui/alarm/AlarmTriggerService.kt @@ -23,6 +23,7 @@ import android.os.VibratorManager import android.util.Log import androidx.core.app.NotificationCompat import androidx.core.app.ServiceCompat +import com.example.slowclock.core.alarm.AlarmSchedulerEntryPoint import com.example.slowclock.core.alarm.R import com.example.slowclock.core.alarm.ScheduleAlarmHelper import com.example.slowclock.core.alarm.SnoozePolicy @@ -467,8 +468,7 @@ class AlarmTriggerService : Service() { val snoozeCount = intent.getIntExtra(EXTRA_SNOOZE_COUNT, 0) if (!SnoozePolicy.canSnooze(snoozeCount)) return val requestCode = intent.getIntExtra(EXTRA_REQUEST_CODE, 0) - ScheduleAlarmHelper.scheduleSnooze( - context = this, + snoozeThrough( baseRequestCode = requestCode, scheduleId = intent.getStringExtra(EXTRA_SCHEDULE_ID).orEmpty(), title = intent.getStringExtra(EXTRA_TITLE) ?: "알람", @@ -494,8 +494,7 @@ class AlarmTriggerService : Service() { stopRinging("다시 알림을 걸 수 없다") return } - ScheduleAlarmHelper.scheduleSnooze( - context = this, + snoozeThrough( baseRequestCode = current.requestCode, scheduleId = current.scheduleId, title = current.title, @@ -506,6 +505,47 @@ class AlarmTriggerService : Service() { stopRinging("다시 알림") } + /** + * 다시 알림을 [AlarmScheduler] 를 거쳐 건다. + * + * 여기서 [ScheduleAlarmHelper] 를 바로 부르면 예약만 되고 기기 안 장부에는 아무것도 남지 + * 않는다. 그러면 재부팅이나 앱 교체로 미뤄 둔 알람이 조용히 사라진다(#177). + * + * 스케줄러를 못 꺼내는 상황이라도 미루기 자체는 되어야 한다. 장부 없이라도 거는 것이 + * 아무 일도 일어나지 않는 버튼보다 낫다. + */ + private fun snoozeThrough( + baseRequestCode: Int, + scheduleId: String, + title: String, + desc: String, + isFullScreen: Boolean, + snoozeCount: Int, + ) { + runCatching { + AlarmSchedulerEntryPoint.from(this).snooze( + baseRequestCode = baseRequestCode, + scheduleId = scheduleId, + title = title, + desc = desc, + isFullScreen = isFullScreen, + snoozeCount = snoozeCount, + ) + }.onFailure { failure -> + Log.e(TAG, "다시 알림을 장부에 남기지 못했다. 예약만 건다", failure) + ScheduleAlarmHelper.scheduleSnooze( + context = this, + baseRequestCode = baseRequestCode, + scheduleId = scheduleId, + title = title, + desc = desc, + isFullScreen = isFullScreen, + snoozeCount = snoozeCount, + triggerTime = SnoozePolicy.nextTriggerAt(System.currentTimeMillis()), + ) + } + } + private fun stopRinging(reason: String) { Log.d(TAG, "알람 울림 종료: $reason") ringing = null diff --git a/core/alarm/src/test/java/com/example/slowclock/core/alarm/ScheduleAlarmHelperTest.kt b/core/alarm/src/test/java/com/example/slowclock/core/alarm/ScheduleAlarmHelperTest.kt index 72325298..963698a8 100644 --- a/core/alarm/src/test/java/com/example/slowclock/core/alarm/ScheduleAlarmHelperTest.kt +++ b/core/alarm/src/test/java/com/example/slowclock/core/alarm/ScheduleAlarmHelperTest.kt @@ -2,6 +2,7 @@ package com.example.slowclock.core.alarm import org.junit.Assert.assertEquals import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test @@ -70,26 +71,48 @@ class ScheduleAlarmHelperTest { } @Test - fun `다시 알림 action 은 비어 있지 않다`() { - // 다시 알림은 requestCode 가 아니라 action 으로 자리를 가른다. 일정 알람 번호가 짝수 - // 전체와 홀수 전체를 덮어 「비어 있는 번호 대역」 이 없기 때문이다. action 이 빈 문자열이면 - // filterEquals 가 일정 알람과 같다고 보아 원래 알람을 덮어쓴다(#129). - assertTrue(ScheduleAlarmHelper.ACTION_SNOOZE_ALARM.isNotBlank()) + fun `다시 알림 자리는 어떤 일정의 알람 자리와도 겹치지 않는다`() { + // PendingIntent 가 같은 자리인지는 requestCode 와 filterEquals(action·component)로 정해진다. + // 일정 알람 번호는 짝수 전체와 홀수 전체, 곧 32비트 정수 전체를 덮어 「비어 있는 번호 + // 대역」 이 없으므로 번호로는 가를 수 없다. action 이 그 일을 한다(#129 · #179). + val slots = (1..300).flatMap { AlarmKind.entries.map { kind -> ScheduleAlarmHelper.slotOf("doc-id-$it", kind) } } + + for (slot in slots) { + for (other in slots) { + assertNotEquals( + "일정 알람 자리 $slot 이 다시 알림 자리와 같다", + slot, + ScheduleAlarmHelper.snoozeSlotOf(other.requestCode), + ) + } + } } @Test - fun `자리 번호는 일정 id 와 종류로만 정해진다`() { - // 다시 알림이 원래 알람과 같은 번호를 쓰므로, 그 번호를 내는 규칙이 흔들리면 - // 미뤄 둔 알람을 취소할 때 엉뚱한 자리를 지운다. - val id = "schedule-1" + fun `다시 알림은 번호가 같고 action 으로만 갈린다`() { + // 번호가 달라지면 원래 알람을 취소할 때 미뤄 둔 알람이 함께 지워지지 않아, 일정을 지운 + // 뒤에도 몇 분 뒤 울린다(#129). + val alarm = ScheduleAlarmHelper.slotOf("schedule-1", AlarmKind.START) + val snooze = ScheduleAlarmHelper.snoozeSlotOf(alarm.requestCode) - assertEquals( - ScheduleAlarmHelper.generateStartRequestCode(id), - ScheduleAlarmHelper.requestCodeOf(id, AlarmKind.START), - ) - assertEquals( - ScheduleAlarmHelper.generateEndRequestCode(id), - ScheduleAlarmHelper.requestCodeOf(id, AlarmKind.END), + assertEquals(alarm.requestCode, snooze.requestCode) + assertNotEquals(alarm.action, snooze.action) + assertNull("일정 알람에 action 을 붙이면 이미 걸린 알람과 filterEquals 가 어긋난다", alarm.action) + assertTrue(snooze.action!!.isNotBlank()) + } + + @Test + fun `같은 일정의 같은 종류는 언제나 같은 자리다`() { + // 예약과 취소가 이 값에서 각각 Intent 를 만든다. 값이 흔들리면 취소가 빗나가고 + // 화면에는 아무 표시도 나지 않는다. + val id = "stable-id" + + AlarmKind.entries.forEach { kind -> + assertEquals(ScheduleAlarmHelper.slotOf(id, kind), ScheduleAlarmHelper.slotOf(id, kind)) + } + assertNotEquals( + ScheduleAlarmHelper.slotOf(id, AlarmKind.START), + ScheduleAlarmHelper.slotOf(id, AlarmKind.END), ) } } diff --git a/core/data/src/main/java/com/example/slowclock/data/remote/repository/ScheduleRepository.kt b/core/data/src/main/java/com/example/slowclock/data/remote/repository/ScheduleRepository.kt index 85900f18..c1419d1c 100644 --- a/core/data/src/main/java/com/example/slowclock/data/remote/repository/ScheduleRepository.kt +++ b/core/data/src/main/java/com/example/slowclock/data/remote/repository/ScheduleRepository.kt @@ -470,6 +470,27 @@ class ScheduleRepository } } + /** + * 사용자가 만든 일정 전부. 알람을 이 기기에 다시 맞추는 데 쓴다. + * + * 오늘 것만으로는 모자란다. 알람 장부는 회차가 아니라 일정 단위로 남고, 며칠 뒤 한 번만 + * 있는 일정도 지금 걸어 두어야 그날 울린다(#176). + * + * 실패하면 null 이다. 빈 목록과 갈라야 한다 — 빈 목록으로 맞추면 걸려 있던 알람을 전부 + * 지우게 된다. + */ + suspend fun getSchedulesOf(userId: String): List? = + try { + schedulesCollection + .whereEqualTo("userId", userId) + .get() + .await() + .mapNotNull { parseScheduleFromDocument(it) } + } catch (e: Exception) { + Log.e("ScheduleRepo", "알람을 맞출 일정 목록 조회 실패", e) + null + } + // 계정 삭제용: 사용자가 만든 일정 전부 삭제 suspend fun deleteAllSchedulesOf(userId: String): Boolean = try { diff --git a/core/data/src/main/java/com/example/slowclock/data/remote/repository/ScheduledAlarmRepository.kt b/core/data/src/main/java/com/example/slowclock/data/remote/repository/ScheduledAlarmRepository.kt index 2cfe5309..63dcf9da 100644 --- a/core/data/src/main/java/com/example/slowclock/data/remote/repository/ScheduledAlarmRepository.kt +++ b/core/data/src/main/java/com/example/slowclock/data/remote/repository/ScheduledAlarmRepository.kt @@ -71,8 +71,83 @@ data class ScheduledAlarm( } fun isLive(nowMillis: Long): Boolean = occurrenceToBook(nowMillis) != null + + /** + * 걸어 둔 회차를 뺀 내용이 [other] 와 같은가. + * + * 서버 목록으로 이 기기의 알람을 맞출 때 쓴다. 같으면 다시 걸지 않고 지금 걸린 회차를 + * 지킨다. 매번 다시 걸면 앱을 열 때마다 방금 미뤄 둔 알람이 지워진다. + */ + fun sameContentAs(other: ScheduledAlarm): Boolean = copy(bookedStartMillis = null) == other.copy(bookedStartMillis = null) } +/** + * 미뤄 둔 알람 하나의 기록. + * + * 다시 알림은 `AlarmManager` 에만 걸려 있어 재부팅과 앱 교체로 사라진다. 미룬 사람은 5분 뒤 + * 울릴 것이라고 믿고 있고, 그 믿음이 이 기능의 전부다. 그래서 걸어 둔 사실을 기기에 남긴다. + * + * [baseRequestCode] 가 이 기록의 열쇠다. 다시 알림은 원래 알람과 자리 번호가 같고 action 으로만 + * 갈리므로, 그 번호 하나가 「어느 알람을 미뤘는가」 를 가리킨다(#129). + */ +@Serializable +data class SnoozedAlarm( + val baseRequestCode: Int, + val scheduleId: String, + val title: String, + val description: String, + val isFullScreen: Boolean, + val snoozeCount: Int, + /** 다시 울릴 시각. 이미 지났으면 복원에서 버린다. */ + val triggerAtMillis: Long, +) + +/** + * 기기 안에만 남는 「미뤄 둔 알람 장부」. + * + * [ScheduledAlarmRepository] 와 파일을 나눈다. 열쇠가 다르고(자리 번호 대 일정 id) 수명도 다르다 + * — 이쪽은 몇 분이면 없어질 기록이다. 한 파일에 섞으면 「전부 읽기」 가 둘을 갈라야 한다. + */ +@Singleton +class SnoozedAlarmRepository + @Inject + constructor( + @ApplicationContext context: Context, + ) { + private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + private val json = Json { ignoreUnknownKeys = true } + + fun save(alarm: SnoozedAlarm) { + prefs.edit().putString(alarm.baseRequestCode.toString(), json.encodeToString(alarm)).apply() + } + + fun remove(baseRequestCode: Int) { + prefs.edit().remove(baseRequestCode.toString()).apply() + } + + fun clear() { + prefs.edit().clear().apply() + } + + /** 장부 전체. 못 읽은 기록은 버린다. 하나가 깨졌다고 나머지까지 못 걸면 안 된다. */ + fun all(): List = + prefs.all.values.mapNotNull { raw -> + (raw as? String)?.let { stored -> + runCatching { json.decodeFromString(stored) } + .onFailure { Log.w(TAG, "깨진 다시 알림 기록을 버린다: ${it.message}") } + .getOrNull() + } + } + + private companion object { + const val TAG = "SnoozedAlarmRepo" + + // scheduled_alarms 와 같은 이유로 백업에서 뺀다 + // (app/src/main/res/xml/data_extraction_rules.xml). + const val PREFS_NAME = "snoozed_alarms" + } + } + /** * 기기 안에만 남는 「걸어 둔 알람 장부」. * diff --git a/core/data/src/test/java/com/example/slowclock/data/remote/repository/ScheduledAlarmTest.kt b/core/data/src/test/java/com/example/slowclock/data/remote/repository/ScheduledAlarmTest.kt index 03902085..c1f86ec5 100644 --- a/core/data/src/test/java/com/example/slowclock/data/remote/repository/ScheduledAlarmTest.kt +++ b/core/data/src/test/java/com/example/slowclock/data/remote/repository/ScheduledAlarmTest.kt @@ -186,4 +186,26 @@ class ScheduledAlarmTest { assertEquals(start + day, record.occurrenceToBook(nowMillis = end + 10)) } + + @Test + fun `걸어 둔 회차만 다른 기록은 내용이 같다고 본다`() { + // 서버 목록으로 알람을 맞출 때 쓴다. 이 판정이 없으면 앱을 열 때마다 전부 다시 걸고, + // 다시 걸기는 그 자리를 먼저 비우므로 방금 미뤄 둔 알람이 그때마다 사라진다(#176). + val start = 1_800_000_000_000 + val booked = daily(start, start + 60 * 60 * 1000, booked = start + day) + val fresh = daily(start, start + 60 * 60 * 1000, booked = null) + + assertTrue(booked.sameContentAs(fresh)) + } + + @Test + fun `제목이나 시각이 바뀌면 내용이 다르다고 본다`() { + val start = 1_800_000_000_000 + val end = start + 60 * 60 * 1000 + val booked = daily(start, end, booked = start) + + assertFalse(booked.sameContentAs(daily(start, end, booked = null).copy(title = "다른 제목"))) + assertFalse(booked.sameContentAs(daily(start + 60_000, end, booked = null))) + assertFalse(booked.sameContentAs(daily(start, end, booked = null).copy(recurrence = Recurrence.WEEKLY.name))) + } } diff --git a/feature/main/src/main/java/com/example/slowclock/ui/main/MainViewModel.kt b/feature/main/src/main/java/com/example/slowclock/ui/main/MainViewModel.kt index 1df9988a..15877c87 100644 --- a/feature/main/src/main/java/com/example/slowclock/ui/main/MainViewModel.kt +++ b/feature/main/src/main/java/com/example/slowclock/ui/main/MainViewModel.kt @@ -43,6 +43,9 @@ class MainViewModel /** 지금 구독이 보고 있는 날. 날이 바뀌면 다시 건다(#171). */ private var subscribedDay: String = "" + /** 알람을 이미 맞춘 사용자. 화면이 다시 만들어져도 한 번만 맞춘다(#176). */ + private var alarmSyncedFor: String? = null + init { observeSignedInUser() // 정확한 알람 권한이 없으면 첫 진입에 한 번만 이유를 설명한다. 설정으로 보내는 건 @@ -251,6 +254,7 @@ class MainViewModel dispatch(MainReducerEvent.UserResolved(uid.orEmpty())) if (uid != null) { observeTodaySchedules() + syncAlarms(uid) } else { scheduleJob?.cancel() scheduleJob = null @@ -260,6 +264,30 @@ class MainViewModel } } + /** + * 서버의 일정으로 이 기기의 알람을 맞춘다. + * + * 알람 장부는 기기 안에만 있고 백업·기기 이전에서 뺐다. 폰을 바꾸거나 앱을 다시 깔면 + * 장부가 빈 채로 시작해, 화면에는 일정이 다 보이는데 알람은 하나도 걸려 있지 않다. + * 사용자가 고칠 방법은 일정을 하나하나 다시 저장하는 것뿐인데 그걸 알 길이 없다(#176). + * + * 목록을 못 읽으면 아무것도 하지 않는다. 빈 목록으로 맞추면 걸려 있던 알람을 전부 지운다. + */ + private fun syncAlarms(uid: String) { + if (alarmSyncedFor == uid) return + alarmSyncedFor = uid + viewModelScope.launch { + val schedules = scheduleRepository.getSchedulesOf(uid) + if (schedules == null) { + Log.w(TAG, "일정 목록을 못 읽어 알람을 맞추지 못했다") + alarmSyncedFor = null + return@launch + } + runCatching { alarmScheduler.syncWith(schedules) } + .onFailure { Log.e(TAG, "알람 맞추기 실패", it) } + } + } + private fun observeTodaySchedules() { scheduleJob?.cancel() subscribedDay = todayKey() diff --git a/feature/main/src/test/java/com/example/slowclock/ui/main/MainViewModelTest.kt b/feature/main/src/test/java/com/example/slowclock/ui/main/MainViewModelTest.kt index 5acc12d9..375d99a3 100644 --- a/feature/main/src/test/java/com/example/slowclock/ui/main/MainViewModelTest.kt +++ b/feature/main/src/test/java/com/example/slowclock/ui/main/MainViewModelTest.kt @@ -57,6 +57,7 @@ class MainViewModelTest { every { settingsRepository.observeShareCode() } returns shareCode every { scheduleRepository.observeSchedulesBySharedCode(any()) } returns flowOf(emptyList()) coEvery { userRepository.getUserNames(any()) } returns emptyMap() + coEvery { scheduleRepository.getSchedulesOf(any()) } returns emptyList() // 기본값은 정시 알람이 허용된 기기다. 안내 다이얼로그를 다루는 테스트만 이 값을 뒤집는다. every { alarmScheduler.canScheduleExactAlarms() } returns true every { settingsRepository.hasSeenExactAlarmNotice() } returns false @@ -70,6 +71,30 @@ class MainViewModelTest { private fun createViewModel() = MainViewModel(scheduleRepository, userRepository, authRepository, settingsRepository, alarmScheduler) + @Test + fun `로그인하면 서버 일정으로 이 기기의 알람을 맞춘다`() = + runTest { + // 알람 장부는 기기 안에만 있어 새 기기·재설치에서는 비어 있다. 맞추지 않으면 화면에 + // 일정은 다 보이는데 알람은 하나도 걸리지 않는다(#176). + coEvery { scheduleRepository.getSchedulesOf("uid-1") } returns listOf(soon, done) + + createViewModel() + + verify(exactly = 1) { alarmScheduler.syncWith(listOf(soon, done)) } + } + + @Test + fun `일정 목록을 못 읽으면 알람을 건드리지 않는다`() = + runTest { + // 빈 목록으로 맞추면 걸려 있던 알람을 전부 지운다. 신호가 약한 것과 일정이 없는 것은 + // 다른 사실이다(#176). + coEvery { scheduleRepository.getSchedulesOf("uid-1") } returns null + + createViewModel() + + verify(exactly = 0) { alarmScheduler.syncWith(any()) } + } + @Test fun `리스너가 낸 오늘 일정으로 상태를 채우고 지금 할 일을 고른다`() = runTest {