Skip to content
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package com.example.slowclock

import android.app.PendingIntent
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.example.slowclock.ui.alarm.AlarmTriggerService
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotEquals
import org.junit.Test
import org.junit.runner.RunWith

/** OS가 extras를 신원에서 제외해도 앞 회차 버튼이 다음 회차로 바뀌지 않는지 확인한다. */
@RunWith(AndroidJUnit4::class)
class AlarmCommandIdentityTest {
private val context = InstrumentationRegistry.getInstrumentation().targetContext

@Test
fun dismissAndSnoozePendingIntentsKeepTheirOwnOccurrence() {
val first = AlarmTriggerService.dismissIntent(context, 42, "first-occurrence")
val second = AlarmTriggerService.dismissIntent(context, 42, "second-occurrence")
val snooze = AlarmTriggerService.snoozeIntent(context, 42, "second-occurrence")
assertFalse(first.filterEquals(second))
assertFalse(second.filterEquals(snooze))
val flags = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
val firstPending = PendingIntent.getService(context, 0, first, flags)
val secondPending = PendingIntent.getService(context, 0, second, flags)
try {
assertNotEquals(firstPending, secondPending)
assertEquals("first-occurrence", first.getStringExtra("alarmCommandToken"))
assertEquals("second-occurrence", second.getStringExtra("alarmCommandToken"))
} finally {
firstPending.cancel()
secondPending.cancel()
}
}

@Test
fun receiverToServiceIntentPreservesSnoozeCounts() {
for (count in 0..2) {
val intent = AlarmTriggerService.ringIntent(context, "약", "", true, "test-schedule", 42, count)
assertEquals(count, intent.getIntExtra(AlarmTriggerService.EXTRA_SNOOZE_COUNT, -1))
assertEquals(42, intent.getIntExtra(AlarmTriggerService.EXTRA_REQUEST_CODE, -1))
assertEquals("test-schedule", intent.getStringExtra(AlarmTriggerService.EXTRA_SCHEDULE_ID))
}
}
}
11 changes: 11 additions & 0 deletions app/src/main/java/com/example/slowclock/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import androidx.core.content.edit
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import com.example.slowclock.auth.AuthManager
import com.example.slowclock.core.alarm.AlarmScheduler
import com.example.slowclock.data.model.ThemeMode
import com.example.slowclock.data.remote.repository.AuthRepository
import com.example.slowclock.data.remote.repository.ScheduleRepository
Expand All @@ -29,6 +30,7 @@ import com.example.slowclock.navigation.AppNavigation
import com.example.slowclock.ui.theme.SlowClockTheme
import com.firebase.ui.auth.AuthUI
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import javax.inject.Inject

Expand All @@ -51,6 +53,9 @@ class MainActivity : ComponentActivity() {
@Inject
lateinit var signOutUseCase: SignOutUseCase

@Inject
lateinit var alarmScheduler: AlarmScheduler

private fun handleNewInstallation() {
val prefs = getSharedPreferences("app_state", MODE_PRIVATE)
val isAppEverLaunched = prefs.getBoolean("app_launched", false)
Expand All @@ -74,6 +79,12 @@ class MainActivity : ComponentActivity() {
Log.d("MAIN", "onCreate 시작")

handleNewInstallation()
// API 32–34에서는 강제 종료 해제 시 BOOT_COMPLETED가 오지 않는다.
// 설치 정리 이후, 네트워크와 독립적으로 프로세스당 한 번만 복원한다.
lifecycleScope.launch(Dispatchers.IO) {
runCatching { alarmScheduler.restoreOnAppStart() }
.onFailure { Log.e("ALARM", "앱 시작 알람 복원 실패", it) }
}

try {
// AuthManager 초기화
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package com.example.slowclock.core.alarm

import android.app.NotificationManager
import android.content.Context
import android.content.Intent
import com.example.slowclock.ui.alarm.AlarmTriggerService

/** OS에 남아 있는 알림이 조작 권한의 수명이다. 취소된 알림의 늦은 버튼은 받지 않는다. */
internal object AlarmNotifications {
const val EXTRA_TOKEN = "alarmCommandToken"
const val EXTRA_SCHEDULE = "alarmCommandSchedule"
const val RINGING_ID = 123
const val CHANNEL_ID = "alarm_ringing_v2"

private val revokedTokens =
java.util.concurrent.ConcurrentHashMap
.newKeySet<String>()

fun canShowControls(context: Context): Boolean {
val manager = manager(context)
if (!manager.areNotificationsEnabled()) return false
return manager.getNotificationChannel(CHANNEL_ID)?.importance != NotificationManager.IMPORTANCE_NONE
}

fun isUsableToken(token: String): Boolean = token !in revokedTokens

fun isCurrent(
context: Context,
notificationId: Int,
token: String?,
): Boolean =
!token.isNullOrBlank() && isUsableToken(token) &&
manager(context).activeNotifications.any {
it.id == notificationId && it.notification.extras.getString(EXTRA_TOKEN) == token
}

fun revoke(token: String) {
revokedTokens.add(token)
}

fun invalidate(
context: Context,
scheduleId: String,
) = removeMatching(context) { it == scheduleId }

fun retainSchedules(
context: Context,
scheduleIds: Set<String>,
) = removeMatching(context) { it !in scheduleIds }

fun clear(context: Context) = removeMatching(context) { true }

private fun removeMatching(
context: Context,
predicate: (String) -> Boolean,
) {
val manager = manager(context)
manager.activeNotifications.forEach { active ->
val scheduleId = active.notification.extras.getString(EXTRA_SCHEDULE) ?: return@forEach
if (!predicate(scheduleId)) return@forEach
active.notification.extras
.getString(EXTRA_TOKEN)
?.let(revokedTokens::add)
manager.cancel(active.tag, active.id)
active.notification.actions?.forEach { it.actionIntent?.cancel() }
active.notification.contentIntent?.cancel()
active.notification.fullScreenIntent?.cancel()
if (active.id == RINGING_ID) {
context.stopService(Intent(context, AlarmTriggerService::class.java))
}
}
}

private fun manager(context: Context) = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,18 +30,32 @@ class AlarmScheduler
private val scheduledAlarms: ScheduledAlarmRepository,
private val snoozedAlarms: SnoozedAlarmRepository,
) {
private var restoredForAppStart = false

/** 프로세스 첫 앱 진입에서만 복원한다. 회전·재진입은 현재 미룸을 건드리지 않는다. */
@Synchronized
fun restoreOnAppStart() {
if (restoredForAppStart) return
restoreAll()
restoredForAppStart = true
}

/**
* 일정의 다음 회차 알람을 건다.
*
* 되풀이하는 일정도 한 번에 하나만 건다. 며칠치를 미리 걸어 두지 않는 이유는, 걸어 둔
* 것이 사라져도(재부팅·강제 종료) 지금 시각에서 다시 세면 그만이기 때문이다. 다음 회차는
* 알람이 울릴 때 [AlarmReceiver] 가 이어서 건다(#130).
*/
@Synchronized
fun schedule(schedule: Schedule) {
AlarmNotifications.invalidate(context, schedule.id)
book(schedule.toRecord(), System.currentTimeMillis())
}

@Synchronized
fun cancel(schedule: Schedule) {
AlarmNotifications.invalidate(context, schedule.id)
ScheduleAlarmHelper.cancelAlarm(context, schedule)
scheduledAlarms.remove(schedule.id)
forgetSnoozesOf(schedule.id)
Expand All @@ -57,12 +71,14 @@ class AlarmScheduler
* 내용이 그대로인 일정은 걸린 회차를 지킨다. 앱을 열 때마다 다시 걸면 방금 미뤄 둔
* 알람이 그때마다 사라진다.
*/
@Synchronized
fun syncWith(schedules: List<Schedule>) {
val nowMillis = System.currentTimeMillis()
val incoming = schedules.associateBy { it.id }
val booked = scheduledAlarms.all()
// 서버에서 사라진 일정의 알람은 지운다. 다른 기기에서 지운 일정이 이 기기에서만
// 계속 울리는 것도 이 자리에서 함께 막힌다.
AlarmNotifications.retainSchedules(context, incoming.keys)
booked.filterNot { incoming.containsKey(it.id) }.forEach { stale ->
ScheduleAlarmHelper.cancelAlarm(context, stale.toSchedule())
scheduledAlarms.remove(stale.id)
Expand All @@ -75,14 +91,22 @@ class AlarmScheduler
snoozedAlarms.remove(stale.baseRequestCode)
}
val bookedById = booked.associateBy { it.id }
var failure: Exception? = null
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)
val same = existing != null && existing.sameContentAs(fresh)
if (!same) AlarmNotifications.invalidate(context, schedule.id)
try {
book(if (same) existing else fresh, nowMillis)
} catch (error: Exception) {
failure = error
}
}
failure?.let { throw it }
}

/**
Expand All @@ -91,6 +115,7 @@ class AlarmScheduler
* 다시 알림은 AlarmManager 에만 걸려 있어 재부팅과 앱 교체로 사라진다. 미룬 사람은 5분
* 뒤 울릴 것이라고 믿고 있고, 그 믿음이 이 기능의 전부다(#177).
*/
@Synchronized
fun snooze(
baseRequestCode: Int,
scheduleId: String,
Expand Down Expand Up @@ -123,7 +148,30 @@ class AlarmScheduler
)
}

/** 알림 확인과 예약·취소는 같은 잠금 안에서 이뤄져 늦은 조작이 취소를 되돌리지 않는다. */
@Synchronized
fun snoozeFromNotification(
baseRequestCode: Int,
scheduleId: String,
title: String,
desc: String,
isFullScreen: Boolean,
snoozeCount: Int,
notificationId: Int,
token: String,
): Boolean {
if (snoozeCount !in 1..SnoozePolicy.MAX_COUNT ||
!AlarmNotifications.isCurrent(context, notificationId, token)
) {
return false
}
snooze(baseRequestCode, scheduleId, title, desc, isFullScreen, snoozeCount)
AlarmNotifications.revoke(token)
return true
}

/** 미뤄 둔 알람이 울렸다. 기록을 지운다. 남기면 다음 부팅에 한 번 더 울린다. */
@Synchronized
fun snoozeFired(baseRequestCode: Int) {
snoozedAlarms.remove(baseRequestCode)
}
Expand All @@ -139,6 +187,7 @@ class AlarmScheduler
* 하지 않는다. 알람을 다시 거는 일은 그 일정의 자리를 먼저 비우므로, 앞당겨 걸면 아직
* 안 울린 종료 알람이 지워진다(#163).
*/
@Synchronized
fun scheduleNextOccurrence(scheduleId: String) {
val record = scheduledAlarms.all().firstOrNull { it.id == scheduleId } ?: return
book(record, System.currentTimeMillis())
Expand Down Expand Up @@ -167,8 +216,11 @@ class AlarmScheduler
}
if (occurrence == record.bookedStartMillis) return

ScheduleAlarmHelper.scheduleAlarm(context, record.toSchedule(occurrence))
// 원하는 회차는 먼저 남기되 성공 표시는 OS 예약이 모두 끝난 뒤에만 쓴다.
// 부분 실패도 null로 남아 다음 복원·동기화에서 다시 시도된다.
scheduledAlarms.save(record.copy(bookedStartMillis = null))
if (!preserveSnoozes) forgetSnoozesOf(record.id)
ScheduleAlarmHelper.scheduleAlarm(context, record.toSchedule(occurrence))
scheduledAlarms.save(record.copy(bookedStartMillis = occurrence))
}

Expand All @@ -177,7 +229,9 @@ class AlarmScheduler
* 알람은 근거를 잃는다. 지우지 않으면 계정을 지운 뒤에도 알람이 울리고 재부팅 뒤에도
* 되살아난다(#127).
*/
@Synchronized
fun cancelAll() {
AlarmNotifications.clear(context)
scheduledAlarms.all().forEach { ScheduleAlarmHelper.cancelAlarm(context, it.toSchedule()) }
scheduledAlarms.clear()
// 마지막 회차가 울리면 일정 장부는 먼저 비워진다. 그 뒤 미룬 알람은 미룸 장부에만
Expand All @@ -203,13 +257,23 @@ class AlarmScheduler
* 이미 지난 알람은 다시 걸지 않고 장부에서 지운다. 세 시간 전 일정이 부팅하자마자 울리는
* 것은 도움이 아니라 오작동이다.
*/
@Synchronized
fun restoreAll() {
val nowMillis = System.currentTimeMillis()
var failure: Exception? = null
scheduledAlarms.all().forEach { record ->
// 재부팅으로 걸린 것이 전부 사라졌으므로, 지키던 회차라도 다시 걸어야 한다.
book(record.copy(bookedStartMillis = null), nowMillis, preserveSnoozes = true)
try {
book(record.copy(bookedStartMillis = null), nowMillis, preserveSnoozes = true)
} catch (error: Exception) {
failure = error
}
}
try {
restoreSnoozes(nowMillis)
} catch (error: Exception) {
failure = error
}
restoreSnoozes(nowMillis)
failure?.let { throw it }
}

/**
Expand All @@ -219,23 +283,29 @@ class AlarmScheduler
* 일정 알람 쪽 취소가 지운다(#177).
*/
private fun restoreSnoozes(nowMillis: Long) {
var failure: Exception? = null
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,
)
try {
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,
)
} catch (error: Exception) {
failure = error
}
}
failure?.let { throw it }
}

/**
Expand All @@ -256,6 +326,9 @@ class AlarmScheduler
*/
fun canUseFullScreenAlarm(): Boolean = context.canUseFullScreenAlarm()

/** 알림을 거부하거나 알람 채널을 끄면 제어할 수 없는 울림을 시작하지 않는다. */
fun canShowAlarmControls(): Boolean = AlarmNotifications.canShowControls(context)

private fun Schedule.toRecord() =
ScheduledAlarm(
id = id,
Expand Down
Loading
Loading