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 a3479463..1cabc5e4 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 @@ -35,37 +35,47 @@ class AlarmScheduler * 알람이 울릴 때 [AlarmReceiver] 가 이어서 건다(#130). */ fun schedule(schedule: Schedule) { - val nowMillis = System.currentTimeMillis() - val record = schedule.toRecord() - if (!record.isLive(nowMillis)) { - // 걸 것이 없으면 자리도 장부도 비운다. 남기면 부팅마다 훑고 버려야 한다. - ScheduleAlarmHelper.cancelAlarm(context, schedule) - scheduledAlarms.remove(record.id) - return - } - ScheduleAlarmHelper.scheduleAlarm(context, record.toSchedule(nowMillis)) - scheduledAlarms.save(record) + book(schedule.toRecord(), System.currentTimeMillis()) + } + + fun cancel(schedule: Schedule) { + ScheduleAlarmHelper.cancelAlarm(context, schedule) + scheduledAlarms.remove(schedule.id) } /** - * 방금 울린 일정의 다음 회차를 건다. 되풀이하지 않는 일정이면 장부에서 지운다. + * 방금 울린 일정의 다음 회차를 건다. 걸 것이 더 없으면 장부에서 지운다. * - * 알람이 울린 그 순간이 다음 회차를 거는 자리다. 여기서 걸지 않으면 되풀이가 한 번으로 - * 끝난다. + * 걸어 둔 회차에 아직 울릴 것이 남아 있으면(시작만 울리고 종료가 남았을 때) 아무것도 + * 하지 않는다. 알람을 다시 거는 일은 그 일정의 자리를 먼저 비우므로, 앞당겨 걸면 아직 + * 안 울린 종료 알람이 지워진다(#163). */ fun scheduleNextOccurrence(scheduleId: String) { - val nowMillis = System.currentTimeMillis() val record = scheduledAlarms.all().firstOrNull { it.id == scheduleId } ?: return - if (!record.isLive(nowMillis)) { + book(record, System.currentTimeMillis()) + } + + /** + * 장부의 기록 하나를 지금 걸어야 할 회차로 맞춘다. + * + * 이미 그 회차가 걸려 있으면 손대지 않는다. 예약·복원·다음 회차가 모두 이 자리를 지나므로 + * 「어느 회차가 걸려 있나」 를 아는 곳이 하나뿐이다. + */ + private fun book( + record: ScheduledAlarm, + nowMillis: Long, + ) { + val occurrence = record.occurrenceToBook(nowMillis) + if (occurrence == null) { + // 걸 것이 없으면 자리도 장부도 비운다. 남기면 부팅마다 훑고 버려야 한다. + ScheduleAlarmHelper.cancelAlarm(context, record.toSchedule()) scheduledAlarms.remove(record.id) return } - ScheduleAlarmHelper.scheduleAlarm(context, record.toSchedule(nowMillis)) - } + if (occurrence == record.bookedStartMillis) return - fun cancel(schedule: Schedule) { - ScheduleAlarmHelper.cancelAlarm(context, schedule) - scheduledAlarms.remove(schedule.id) + ScheduleAlarmHelper.scheduleAlarm(context, record.toSchedule(occurrence)) + scheduledAlarms.save(record.copy(bookedStartMillis = occurrence)) } /** @@ -78,11 +88,10 @@ class AlarmScheduler scheduledAlarms.clear() } - /** 알람을 거는 쪽이 보는 값. 장부의 기록을 다음 회차로 옮겨 놓은 일정이다. */ - private fun ScheduledAlarm.toSchedule(nowMillis: Long): Schedule { - val next = nextTriggerAfter(nowMillis) ?: return toSchedule() - // 반복 일정은 다음 회차의 시작으로 옮긴다. 종료는 시작에서 떨어진 만큼 함께 옮긴다. - val shifted = if (rule == Recurrence.NONE) 0L else next - startMillis + /** 알람을 거는 쪽이 보는 값. 장부의 기록을 [occurrenceStartMillis] 회차로 옮겨 놓은 일정이다. */ + private fun ScheduledAlarm.toSchedule(occurrenceStartMillis: Long): Schedule { + // 종료는 시작에서 떨어진 만큼 함께 옮긴다. 자정을 넘는 일정도 길이가 유지된다. + val shifted = occurrenceStartMillis - startMillis return toSchedule().copy( startTime = Timestamp(Date(startMillis + shifted)), endTime = endMillis?.let { Timestamp(Date(it + shifted)) }, @@ -98,9 +107,10 @@ class AlarmScheduler */ fun restoreAll() { val nowMillis = System.currentTimeMillis() - val (live, expired) = scheduledAlarms.all().partition { it.isLive(nowMillis) } - expired.forEach { scheduledAlarms.remove(it.id) } - live.forEach { ScheduleAlarmHelper.scheduleAlarm(context, it.toSchedule(nowMillis)) } + scheduledAlarms.all().forEach { record -> + // 재부팅으로 걸린 것이 전부 사라졌으므로, 지키던 회차라도 다시 걸어야 한다. + book(record.copy(bookedStartMillis = null), nowMillis) + } } /** 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 ebb816c4..2cfe5309 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 @@ -26,27 +26,51 @@ data class ScheduledAlarm( val endMillis: Long? = null, /** [Recurrence] 의 이름. 옛 기록에는 없으므로 기본값은 되풀이 없음이다. */ val recurrence: String = Recurrence.NONE.name, + /** + * 지금 기기에 걸어 둔 회차의 시작 시각. 아직 아무것도 안 걸었으면 null. + * + * 이 값이 없으면 「지금 걸린 것이 어느 회차인가」 를 알 수 없다. 그러면 시작 알람이 울린 + * 직후 다음 회차를 앞당겨 걸면서, 아직 안 울린 그 회차의 종료 알람까지 지운다 — 자리가 + * 일정마다 시작 하나·종료 하나뿐이기 때문이다(#163). + */ + val bookedStartMillis: Long? = null, ) { val rule: Recurrence get() = runCatching { Recurrence.valueOf(recurrence) }.getOrDefault(Recurrence.NONE) + /** 시작에서 종료까지의 길이. 종료가 없으면 0 이다. */ + private val durationMillis: Long + get() = endMillis?.let { it - startMillis } ?: 0L + /** - * [nowMillis] 뒤에 울릴 첫 시각. 더 없으면 null. + * 지금 걸어 두어야 할 회차의 시작 시각. 걸 것이 더 없으면 null. * - * 되풀이하지 않는 일정은 시작이나 종료 중 아직 안 온 것이 있을 때만 값이 있다. 되풀이하는 - * 일정은 언제 물어도 값이 있다 — 끝이 없기 때문이다. + * **걸어 둔 회차에 아직 울릴 것이 남아 있으면 그 회차를 그대로 지킨다.** 앞당겨 걸면 + * 남은 알람이 지워지기 때문이다(#163). 그래서 되풀이하는 일정은 종료 알람까지 울린 뒤에야 + * 다음 회차로 넘어간다. * * 경계는 알람을 거는 쪽과 같은 `> now` 다. 기준이 갈리면 장부에는 있는데 걸리지는 않는 * 유령 기록이 쌓인다. */ - fun nextTriggerAfter(nowMillis: Long): Long? { + fun occurrenceToBook(nowMillis: Long): Long? { + val booked = bookedStartMillis + if (booked != null && (booked > nowMillis || booked + durationMillis > nowMillis)) { + return booked + } if (rule == Recurrence.NONE) { - return listOfNotNull(startMillis, endMillis).filter { it > nowMillis }.minOrNull() + // 되풀이하지 않는 일정은 첫 회차가 전부다. 시작이 지났어도 종료가 남았으면 건다. + return startMillis.takeIf { it > nowMillis || it + durationMillis > nowMillis } } + // 아직 아무것도 안 걸었는데 진행 중인 회차가 있으면 그것부터 건다. 시작이 지난 뒤에 + // 저장하거나 재부팅한 경우다. 다음 회차부터 세면 오늘 종료 알람을 통째로 놓친다(#163). + RecurrenceRule + .occurrenceOn(startMillis, rule, nowMillis) + ?.takeIf { it + durationMillis > nowMillis } + ?.let { return it } return RecurrenceRule.nextOccurrenceAfter(startMillis, rule, nowMillis) } - fun isLive(nowMillis: Long): Boolean = nextTriggerAfter(nowMillis) != null + fun isLive(nowMillis: Long): Boolean = occurrenceToBook(nowMillis) != null } /** 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 85073fb4..03902085 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 @@ -1,8 +1,10 @@ package com.example.slowclock.data.remote.repository +import com.example.slowclock.util.Recurrence import kotlinx.serialization.json.Json import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test import java.util.TimeZone @@ -88,4 +90,100 @@ class ScheduledAlarmTest { TimeZone.setDefault(original) } } + + // ── 걸어 둔 회차 (#163) ──────────────────────────────────────────────── + + private fun daily( + start: Long, + end: Long? = null, + booked: Long? = null, + ) = ScheduledAlarm( + id = "daily-1", + title = "물리치료", + description = "", + startMillis = start, + endMillis = end, + recurrence = Recurrence.DAILY.name, + bookedStartMillis = booked, + ) + + private val day = 24 * 60 * 60 * 1000L + + @Test + fun `시작만 울리고 종료가 남았으면 그 회차를 그대로 지킨다`() { + // 앞당겨 걸면 알람을 다시 거는 일이 그 일정의 자리를 먼저 비우므로, 아직 안 울린 + // 종료 알람이 지워진다. 그러면 반복 일정의 종료는 영영 울리지 않는다(#163). + val start = 1_800_000_000_000 + val end = start + 60 * 60 * 1000 + val record = daily(start, end, booked = start) + + assertEquals(start, record.occurrenceToBook(nowMillis = start + 10)) + } + + @Test + fun `종료까지 울린 뒤에야 다음 회차로 넘어간다`() { + val start = 1_800_000_000_000 + val end = start + 60 * 60 * 1000 + val record = daily(start, end, booked = start) + + assertEquals(start + day, record.occurrenceToBook(nowMillis = end + 10)) + } + + @Test + fun `종료가 없는 반복 일정은 시작이 울리면 바로 다음 회차로 넘어간다`() { + val start = 1_800_000_000_000 + val record = daily(start, end = null, booked = start) + + assertEquals(start + day, record.occurrenceToBook(nowMillis = start + 10)) + } + + @Test + fun `아직 아무것도 안 걸었으면 지금 이후 첫 회차를 낸다`() { + val start = 1_800_000_000_000 + val record = daily(start, end = null, booked = null) + + assertEquals(start, record.occurrenceToBook(nowMillis = start - 10)) + assertEquals(start + day, record.occurrenceToBook(nowMillis = start + 10)) + } + + @Test + fun `되풀이하지 않는 일정도 시작이 지났어도 종료가 남았으면 건다`() { + val start = 1_800_000_000_000 + val end = start + 60 * 60 * 1000 + val once = ScheduledAlarm("once-1", "병원", "", start, end) + + assertEquals(start, once.occurrenceToBook(nowMillis = start + 10)) + assertNull(once.occurrenceToBook(nowMillis = end + 10)) + } + + @Test + fun `옛 기록은 걸어 둔 회차가 없어도 읽힌다`() { + // bookedStartMillis 가 없던 판에서 넘어온 JSON 이다. 못 읽으면 그 알람이 통째로 사라진다. + val stored = """{"id":"old-1","title":"약","description":"","startMillis":1800000000000}""" + + val restored = json.decodeFromString(stored) + + assertNull(restored.bookedStartMillis) + assertEquals(Recurrence.NONE, restored.rule) + } + + @Test + fun `시작이 지난 뒤에 저장해도 그날 종료 알람은 건다`() { + // 09시 30분에 「매일 09~10시」 를 저장하면 오늘 회차가 진행 중이다. 다음 회차부터 + // 세면 오늘 10시 종료 알람을 통째로 놓친다(#163). + val start = 1_800_000_000_000 + val end = start + 60 * 60 * 1000 + val record = daily(start, end, booked = null) + + assertEquals(start, record.occurrenceToBook(nowMillis = start + 30 * 60 * 1000)) + } + + @Test + fun `진행 중인 회차가 없으면 다음 회차를 낸다`() { + val start = 1_800_000_000_000 + val end = start + 60 * 60 * 1000 + val record = daily(start, end, booked = null) + + assertEquals(start + day, record.occurrenceToBook(nowMillis = end + 10)) + } }