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 {