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/build.gradle.kts b/core/alarm/build.gradle.kts
index ce5e42b6..25835fbe 100644
--- a/core/alarm/build.gradle.kts
+++ b/core/alarm/build.gradle.kts
@@ -12,6 +12,7 @@ dependencies {
// 반복 규칙을 알람 경로가 함께 본다(#130).
implementation(project(":core:common"))
testImplementation(libs.junit)
+ testImplementation(libs.mockk)
implementation(project(":core:data")) // Notifier 인터페이스 구현 (역결합 제거)
implementation(libs.androidx.core.ktx)
implementation(libs.firebase.messaging)
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..d1c13be9 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,92 @@ 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)
+ }
+ // 마지막 회차가 끝난 미룸은 일정 장부에 없을 수 있다. 다른 기기에서 그 일정을
+ // 지웠다면 미룸 예약도 따로 찾아 지운다.
+ snoozedAlarms.all().filterNot { incoming.containsKey(it.scheduleId) }.forEach { stale ->
+ ScheduleAlarmHelper.cancelSnooze(context, stale.baseRequestCode)
+ snoozedAlarms.remove(stale.baseRequestCode)
+ }
+ 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) }
}
/**
@@ -60,21 +149,26 @@ class AlarmScheduler
*
* 이미 그 회차가 걸려 있으면 손대지 않는다. 예약·복원·다음 회차가 모두 이 자리를 지나므로
* 「어느 회차가 걸려 있나」 를 아는 곳이 하나뿐이다.
+ * [preserveSnoozes] 는 재부팅 복원 때만 쓴다. 그때는 미룸을 뒤에서 다시 걸고, 그 밖의
+ * 재예약은 기존 미룸 예약을 취소하므로 복원 장부도 함께 지워야 한다.
*/
private fun book(
record: ScheduledAlarm,
nowMillis: Long,
+ preserveSnoozes: Boolean = false,
) {
val occurrence = record.occurrenceToBook(nowMillis)
if (occurrence == null) {
// 걸 것이 없으면 자리도 장부도 비운다. 남기면 부팅마다 훑고 버려야 한다.
ScheduleAlarmHelper.cancelAlarm(context, record.toSchedule())
scheduledAlarms.remove(record.id)
+ if (!preserveSnoozes) forgetSnoozesOf(record.id)
return
}
if (occurrence == record.bookedStartMillis) return
ScheduleAlarmHelper.scheduleAlarm(context, record.toSchedule(occurrence))
+ if (!preserveSnoozes) forgetSnoozesOf(record.id)
scheduledAlarms.save(record.copy(bookedStartMillis = occurrence))
}
@@ -86,6 +180,10 @@ class AlarmScheduler
fun cancelAll() {
scheduledAlarms.all().forEach { ScheduleAlarmHelper.cancelAlarm(context, it.toSchedule()) }
scheduledAlarms.clear()
+ // 마지막 회차가 울리면 일정 장부는 먼저 비워진다. 그 뒤 미룬 알람은 미룸 장부에만
+ // 있으므로, 일정 장부와 별개로 예약을 취소해야 로그아웃 뒤에도 울리지 않는다.
+ snoozedAlarms.all().forEach { ScheduleAlarmHelper.cancelSnooze(context, it.baseRequestCode) }
+ snoozedAlarms.clear()
}
/** 알람을 거는 쪽이 보는 값. 장부의 기록을 [occurrenceStartMillis] 회차로 옮겨 놓은 일정이다. */
@@ -109,7 +207,34 @@ class AlarmScheduler
val nowMillis = System.currentTimeMillis()
scheduledAlarms.all().forEach { record ->
// 재부팅으로 걸린 것이 전부 사라졌으므로, 지키던 회차라도 다시 걸어야 한다.
- book(record.copy(bookedStartMillis = null), nowMillis)
+ book(record.copy(bookedStartMillis = null), nowMillis, preserveSnoozes = true)
+ }
+ 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..bead1bd9 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,
)
@@ -220,20 +237,29 @@ internal object ScheduleAlarmHelper {
}
/** 미뤄 둔 다시 알림 하나를 지운다. 자리는 일정 알람과 번호가 같고 action 으로 갈린다. */
+ fun cancelSnooze(
+ context: Context,
+ baseRequestCode: Int,
+ ) {
+ val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
+ cancelSnooze(context, alarmManager, baseRequestCode)
+ }
+
private fun cancelSnooze(
context: Context,
alarmManager: AlarmManager,
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 +282,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 +298,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 +327,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/AlarmSchedulerSnoozeTest.kt b/core/alarm/src/test/java/com/example/slowclock/core/alarm/AlarmSchedulerSnoozeTest.kt
new file mode 100644
index 00000000..3a920d86
--- /dev/null
+++ b/core/alarm/src/test/java/com/example/slowclock/core/alarm/AlarmSchedulerSnoozeTest.kt
@@ -0,0 +1,162 @@
+package com.example.slowclock.core.alarm
+
+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 io.mockk.every
+import io.mockk.mockk
+import io.mockk.mockkObject
+import io.mockk.unmockkObject
+import io.mockk.verify
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import java.util.Date
+
+class AlarmSchedulerSnoozeTest {
+ private val now = System.currentTimeMillis()
+ private val context = mockk()
+ private val scheduledRepository = mockk()
+ private val snoozedRepository = mockk()
+ private val scheduled = mutableMapOf()
+ private val snoozed = mutableMapOf()
+ private val scheduler = AlarmScheduler(context, scheduledRepository, snoozedRepository)
+ private val record =
+ ScheduledAlarm(
+ id = "s1",
+ title = "약",
+ description = "",
+ startMillis = now - 3_600_000,
+ recurrence = Recurrence.DAILY.name,
+ bookedStartMillis = now - 3_600_000,
+ )
+ private val snooze =
+ SnoozedAlarm(
+ baseRequestCode = ScheduleAlarmHelper.generateStartRequestCode(record.id),
+ scheduleId = record.id,
+ title = "약 (시작)",
+ description = "",
+ isFullScreen = true,
+ snoozeCount = 1,
+ triggerAtMillis = now + 300_000,
+ )
+
+ @Before
+ fun setUp() {
+ scheduled[record.id] = record
+ snoozed[snooze.baseRequestCode] = snooze
+ every { scheduledRepository.all() } answers { scheduled.values.toList() }
+ every { scheduledRepository.save(any()) } answers {
+ val value = firstArg()
+ scheduled[value.id] = value
+ }
+ every { scheduledRepository.remove(any()) } answers {
+ scheduled.remove(firstArg())
+ Unit
+ }
+ every { snoozedRepository.all() } answers { snoozed.values.toList() }
+ every { snoozedRepository.remove(any()) } answers {
+ snoozed.remove(firstArg())
+ Unit
+ }
+ mockkObject(ScheduleAlarmHelper)
+ every { ScheduleAlarmHelper.scheduleAlarm(context, any(), any()) } returns Unit
+ every { ScheduleAlarmHelper.cancelAlarm(context, any()) } returns Unit
+ every { ScheduleAlarmHelper.cancelSnooze(context, any()) } returns Unit
+ every { ScheduleAlarmHelper.scheduleSnooze(context, any(), any(), any(), any(), any(), any(), any()) } returns Unit
+ }
+
+ @After
+ fun tearDown() {
+ unmockkObject(ScheduleAlarmHelper)
+ }
+
+ @Test
+ fun `재부팅 복원은 다음 회차를 다시 걸어도 미룸 시각과 횟수를 지킨다`() {
+ scheduler.restoreAll()
+
+ assertEquals(snooze, snoozed[snooze.baseRequestCode])
+ verify(exactly = 1) {
+ ScheduleAlarmHelper.scheduleSnooze(
+ context,
+ snooze.baseRequestCode,
+ snooze.scheduleId,
+ snooze.title,
+ snooze.description,
+ snooze.isFullScreen,
+ snooze.snoozeCount,
+ snooze.triggerAtMillis,
+ )
+ }
+ }
+
+ @Test
+ fun `일정을 수정해 취소한 미룸은 재부팅해도 되살아나지 않는다`() {
+ scheduler.schedule(scheduleOf(record).copy(title = "바꾼 약", startTime = Timestamp(Date(now + 3_600_000))))
+ assertTrue(snoozed.isEmpty())
+
+ scheduler.restoreAll()
+
+ verify(exactly = 0) { ScheduleAlarmHelper.scheduleSnooze(context, any(), any(), any(), any(), any(), any(), any()) }
+ }
+
+ @Test
+ fun `일정을 삭제해 취소한 미룸은 재부팅해도 되살아나지 않는다`() {
+ scheduler.cancel(scheduleOf(record))
+ assertTrue(snoozed.isEmpty())
+
+ scheduler.restoreAll()
+
+ verify(exactly = 0) { ScheduleAlarmHelper.scheduleSnooze(context, any(), any(), any(), any(), any(), any(), any()) }
+ }
+
+ @Test
+ fun `다음 회차 예약이 취소한 미룸은 재부팅해도 되살아나지 않는다`() {
+ scheduler.scheduleNextOccurrence(record.id)
+ assertTrue(snoozed.isEmpty())
+
+ scheduler.restoreAll()
+
+ verify(exactly = 0) { ScheduleAlarmHelper.scheduleSnooze(context, any(), any(), any(), any(), any(), any(), any()) }
+ }
+
+ @Test
+ fun `내용과 회차가 그대로인 서버 동기화는 미룸을 지킨다`() {
+ val ongoing = record.copy(endMillis = now + 3_600_000)
+ scheduled[record.id] = ongoing
+
+ scheduler.syncWith(listOf(scheduleOf(ongoing)))
+
+ assertEquals(snooze, snoozed[snooze.baseRequestCode])
+ verify(exactly = 0) { ScheduleAlarmHelper.scheduleAlarm(context, any(), any()) }
+ verify(exactly = 0) { ScheduleAlarmHelper.cancelAlarm(context, any()) }
+ }
+
+ @Test
+ fun `서버에서 삭제된 일정은 일정 장부 없이 남은 미룸도 취소한다`() {
+ scheduled.clear()
+
+ scheduler.syncWith(emptyList())
+
+ assertTrue(snoozed.isEmpty())
+ verify(exactly = 1) { ScheduleAlarmHelper.cancelSnooze(context, snooze.baseRequestCode) }
+ }
+
+ private fun scheduleOf(record: ScheduledAlarm) =
+ Schedule(
+ id = record.id,
+ title = record.title,
+ description = record.description,
+ startTime = Timestamp(Date(record.startMillis)),
+ endTime = record.endMillis?.let { Timestamp(Date(it)) },
+ recurring = true,
+ recurringType = Recurrence.DAILY.type,
+ )
+}
diff --git a/core/alarm/src/test/java/com/example/slowclock/core/alarm/AlarmSchedulerTest.kt b/core/alarm/src/test/java/com/example/slowclock/core/alarm/AlarmSchedulerTest.kt
new file mode 100644
index 00000000..7347ed40
--- /dev/null
+++ b/core/alarm/src/test/java/com/example/slowclock/core/alarm/AlarmSchedulerTest.kt
@@ -0,0 +1,44 @@
+package com.example.slowclock.core.alarm
+
+import android.content.Context
+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 io.mockk.every
+import io.mockk.mockk
+import io.mockk.mockkObject
+import io.mockk.unmockkObject
+import io.mockk.verify
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class AlarmSchedulerTest {
+ @Test
+ fun `마지막 회차가 끝나 일정 장부가 비어도 미룬 예약을 모두 취소한다`() {
+ val context = mockk()
+ val scheduled = mockk(relaxed = true)
+ val snoozed = mockk(relaxed = true)
+ val snoozes =
+ listOf(42, 43).map { requestCode ->
+ SnoozedAlarm(requestCode, "ended", "약", "", true, 1, System.currentTimeMillis() + 300_000)
+ }
+ // 마지막 알람은 수신할 때 일정 장부에서 빠진다. 그 뒤 사용자가 미루면 이 목록만 남는다.
+ every { scheduled.all() } returns emptyList()
+ every { snoozed.all() } returns snoozes
+ val pending = snoozes.map { it.baseRequestCode }.toMutableSet()
+ mockkObject(ScheduleAlarmHelper)
+ try {
+ every { ScheduleAlarmHelper.cancelSnooze(context, any()) } answers {
+ pending.remove(secondArg())
+ Unit
+ }
+
+ AlarmScheduler(context, scheduled, snoozed).cancelAll()
+
+ assertTrue("로그아웃 뒤에도 미룸 예약이 남아 있다", pending.isEmpty())
+ verify(exactly = 1) { snoozed.clear() }
+ } finally {
+ unmockkObject(ScheduleAlarmHelper)
+ }
+ }
+}
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 ae084185..d1826b00 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
@@ -12,6 +12,8 @@ import com.google.firebase.auth.FirebaseAuth
import com.google.firebase.firestore.FieldValue
import com.google.firebase.firestore.FirebaseFirestore
import com.google.firebase.firestore.FirebaseFirestoreException
+import com.google.firebase.firestore.Source
+import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
@@ -470,6 +472,29 @@ class ScheduleRepository
}
}
+ /**
+ * 사용자가 만든 일정 전부. 알람을 이 기기에 다시 맞추는 데 쓴다.
+ *
+ * 오늘 것만으로는 모자란다. 알람 장부는 회차가 아니라 일정 단위로 남고, 며칠 뒤 한 번만
+ * 있는 일정도 지금 걸어 두어야 그날 울린다(#176).
+ *
+ * 실패하면 null 이다. 빈 목록과 갈라야 한다 — 빈 목록으로 맞추면 걸려 있던 알람을 전부
+ * 지우게 된다. 캐시는 일부 문서만 담을 수 있으므로 서버 응답으로만 맞춘다.
+ */
+ suspend fun getSchedulesOf(userId: String): List? =
+ try {
+ schedulesCollection
+ .whereEqualTo("userId", userId)
+ .get(Source.SERVER)
+ .await()
+ .mapNotNull { parseScheduleFromDocument(it) }
+ } catch (e: CancellationException) {
+ throw e
+ } catch (e: Exception) {
+ Log.e("ScheduleRepo", "알람을 맞출 일정 목록 조회 실패", e)
+ null
+ }
+
/**
* 공유 코드 없이 저장된 내 일정에 코드를 채운다. 채운 건수를 낸다.
*
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/ScheduleRepositoryAlarmSyncTest.kt b/core/data/src/test/java/com/example/slowclock/data/remote/repository/ScheduleRepositoryAlarmSyncTest.kt
new file mode 100644
index 00000000..528dce3a
--- /dev/null
+++ b/core/data/src/test/java/com/example/slowclock/data/remote/repository/ScheduleRepositoryAlarmSyncTest.kt
@@ -0,0 +1,67 @@
+package com.example.slowclock.data.remote.repository
+
+import com.google.android.gms.tasks.Task
+import com.google.android.gms.tasks.Tasks
+import com.google.firebase.auth.FirebaseAuth
+import com.google.firebase.firestore.CollectionReference
+import com.google.firebase.firestore.FirebaseFirestore
+import com.google.firebase.firestore.FirebaseFirestoreException
+import com.google.firebase.firestore.Query
+import com.google.firebase.firestore.QueryDocumentSnapshot
+import com.google.firebase.firestore.QuerySnapshot
+import com.google.firebase.firestore.Source
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.verify
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class ScheduleRepositoryAlarmSyncTest {
+ private fun repositoryWith(serverResponse: Task): Pair {
+ val emptyCache = mockk()
+ every { emptyCache.iterator() } returns mutableListOf().iterator()
+ val query = mockk()
+ every { query.get() } returns Tasks.forResult(emptyCache)
+ every { query.get(Source.SERVER) } returns serverResponse
+ val schedules = mockk()
+ every { schedules.whereEqualTo("userId", "uid-1") } returns query
+ val firestore = mockk()
+ every { firestore.collection("schedules") } returns schedules
+ every { firestore.collection("users") } returns mockk()
+ return ScheduleRepository(mockk(), firestore) to query
+ }
+
+ @Test
+ fun `서버에 닿지 못하면 빈 캐시를 일정이 없는 것으로 돌려주지 않는다`() =
+ runTest {
+ val unavailable = FirebaseFirestoreException("offline", FirebaseFirestoreException.Code.UNAVAILABLE)
+ val (repository, query) = repositoryWith(Tasks.forException(unavailable))
+
+ assertNull(repository.getSchedulesOf("uid-1"))
+ verify(exactly = 0) { query.get() }
+ }
+
+ @Test
+ fun `서버가 빈 목록을 확인했으면 빈 목록으로 돌려준다`() =
+ runTest {
+ val emptyServer = mockk()
+ every { emptyServer.iterator() } returns mutableListOf().iterator()
+ val (repository, _) = repositoryWith(Tasks.forResult(emptyServer))
+
+ assertEquals(emptyList(), repository.getSchedulesOf("uid-1"))
+ }
+
+ @Test
+ fun `취소한 알람 목록 조회는 일반 실패로 삼키지 않는다`() =
+ runTest {
+ val (repository, _) = repositoryWith(Tasks.forCanceled())
+
+ val failure = runCatching { repository.getSchedulesOf("uid-1") }.exceptionOrNull()
+
+ assertTrue(failure is CancellationException)
+ }
+}
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 64ecb7d0..b21eb0cc 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
@@ -13,8 +13,11 @@ import com.example.slowclock.util.toAppError
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
+import kotlinx.coroutines.currentCoroutineContext
+import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.launch
@@ -43,6 +46,10 @@ class MainViewModel
/** 지금 구독이 보고 있는 날. 날이 바뀌면 다시 건다(#171). */
private var subscribedDay: String = ""
+ /** 알람을 이미 맞춘 사용자. 화면이 다시 만들어져도 한 번만 맞춘다(#176). */
+ private var alarmSyncedFor: String? = null
+ private var alarmSyncJob: Job? = null
+
init {
observeSignedInUser()
// 정확한 알람 권한이 없으면 첫 진입에 한 번만 이유를 설명한다. 설정으로 보내는 건
@@ -58,11 +65,13 @@ class MainViewModel
when (intent) {
MainIntent.ScreenResumed -> {
resubscribeIfDayChanged()
+ authRepository.currentUid?.let(::syncAlarms)
}
MainIntent.Retry -> {
dispatch(MainReducerEvent.ErrorConsumed)
observeTodaySchedules()
+ authRepository.currentUid?.let(::syncAlarms)
}
is MainIntent.ToggleComplete -> {
@@ -247,10 +256,16 @@ class MainViewModel
*/
private fun observeSignedInUser() {
viewModelScope.launch {
- authRepository.observeCurrentUid().collect { uid ->
+ authRepository.observeCurrentUid().distinctUntilChanged().collect { uid ->
+ // 로그아웃 뒤 늦게 돌아온 목록이 알람을 되살리지 않게 이전 조회를 끊는다.
+ // 같은 계정으로 다시 로그인해도 로그아웃이 비운 알람은 다시 맞춰야 한다.
+ alarmSyncJob?.cancel()
+ alarmSyncJob = null
+ alarmSyncedFor = null
dispatch(MainReducerEvent.UserResolved(uid.orEmpty()))
if (uid != null) {
observeTodaySchedules()
+ syncAlarms(uid)
} else {
scheduleJob?.cancel()
scheduleJob = null
@@ -260,6 +275,32 @@ class MainViewModel
}
}
+ /**
+ * 서버의 일정으로 이 기기의 알람을 맞춘다.
+ *
+ * 알람 장부는 기기 안에만 있고 백업·기기 이전에서 뺐다. 폰을 바꾸거나 앱을 다시 깔면
+ * 장부가 빈 채로 시작해, 화면에는 일정이 다 보이는데 알람은 하나도 걸려 있지 않다.
+ * 사용자가 고칠 방법은 일정을 하나하나 다시 저장하는 것뿐인데 그걸 알 길이 없다(#176).
+ *
+ * 목록을 못 읽으면 아무것도 하지 않는다. 빈 목록으로 맞추면 걸려 있던 알람을 전부 지운다.
+ */
+ private fun syncAlarms(uid: String) {
+ if (alarmSyncedFor == uid || alarmSyncJob?.isActive == true) return
+ alarmSyncJob =
+ viewModelScope.launch {
+ val schedules = scheduleRepository.getSchedulesOf(uid)
+ currentCoroutineContext().ensureActive()
+ if (authRepository.currentUid != uid) return@launch
+ if (schedules == null) {
+ Log.w(TAG, "일정 목록을 못 읽어 알람을 맞추지 못했다")
+ return@launch
+ }
+ runCatching { alarmScheduler.syncWith(schedules) }
+ .onSuccess { alarmSyncedFor = uid }
+ .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 65040f5c..427cf7ab 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
@@ -14,8 +14,10 @@ import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
+import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
@@ -23,6 +25,7 @@ import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
+import kotlinx.coroutines.withContext
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
@@ -57,6 +60,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()
coEvery { userRepository.registerShareCodeWatcher(any()) } returns true
// 기본값은 정시 알람이 허용된 기기다. 안내 다이얼로그를 다루는 테스트만 이 값을 뒤집는다.
every { alarmScheduler.canScheduleExactAlarms() } returns true
@@ -71,6 +75,98 @@ 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 {
+ val uid = MutableStateFlow("uid-1")
+ every { authRepository.observeCurrentUid() } returns uid
+ every { authRepository.currentUid } answers { uid.value }
+ val pending = CompletableDeferred?>()
+ coEvery { scheduleRepository.getSchedulesOf("uid-1") } coAnswers {
+ withContext(NonCancellable) { pending.await() }
+ }
+ createViewModel()
+
+ uid.value = null
+ pending.complete(listOf(soon))
+
+ verify(exactly = 0) { alarmScheduler.syncWith(any()) }
+ }
+
+ @Test
+ fun `계정을 바꾼 뒤 앞 계정의 응답이 새 계정 알람을 덮지 않는다`() =
+ runTest {
+ val uid = MutableStateFlow("uid-1")
+ every { authRepository.observeCurrentUid() } returns uid
+ every { authRepository.currentUid } answers { uid.value }
+ val pending = CompletableDeferred?>()
+ coEvery { scheduleRepository.getSchedulesOf("uid-1") } coAnswers {
+ withContext(NonCancellable) { pending.await() }
+ }
+ val other = soon.copy(id = "other-schedule", userId = "uid-2")
+ coEvery { scheduleRepository.getSchedulesOf("uid-2") } returns listOf(other)
+ createViewModel()
+
+ uid.value = "uid-2"
+ pending.complete(listOf(soon))
+
+ verify(exactly = 1) { alarmScheduler.syncWith(listOf(other)) }
+ verify(exactly = 0) { alarmScheduler.syncWith(listOf(soon)) }
+ }
+
+ @Test
+ fun `같은 계정으로 다시 로그인해도 비운 알람을 다시 맞춘다`() =
+ runTest {
+ val uid = MutableStateFlow("uid-1")
+ every { authRepository.observeCurrentUid() } returns uid
+ every { authRepository.currentUid } answers { uid.value }
+ coEvery { scheduleRepository.getSchedulesOf("uid-1") } returns listOf(soon)
+ createViewModel()
+
+ uid.value = null
+ uid.value = "uid-1"
+
+ verify(exactly = 2) { alarmScheduler.syncWith(listOf(soon)) }
+ }
+
+ @Test
+ fun `서버를 못 읽었다가 화면으로 돌아오면 알람 맞추기를 다시 시도한다`() =
+ runTest {
+ coEvery { scheduleRepository.getSchedulesOf("uid-1") } returnsMany listOf(null, listOf(soon))
+ val viewModel = createViewModel()
+ verify(exactly = 0) { alarmScheduler.syncWith(any()) }
+
+ viewModel.onIntent(MainIntent.ScreenResumed)
+ viewModel.onIntent(MainIntent.ScreenResumed)
+
+ verify(exactly = 1) { alarmScheduler.syncWith(listOf(soon)) }
+ coVerify(exactly = 2) { scheduleRepository.getSchedulesOf("uid-1") }
+ }
+
@Test
fun `리스너가 낸 오늘 일정으로 상태를 채우고 지금 할 일을 고른다`() =
runTest {