Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}

/**
Expand All @@ -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)) },
Expand All @@ -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)
}
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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<ScheduledAlarm>(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))
}
}
Loading