diff --git a/app/src/androidTest/java/com/example/slowclock/AlarmCommandIdentityTest.kt b/app/src/androidTest/java/com/example/slowclock/AlarmCommandIdentityTest.kt new file mode 100644 index 00000000..a045cd08 --- /dev/null +++ b/app/src/androidTest/java/com/example/slowclock/AlarmCommandIdentityTest.kt @@ -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)) + } + } +} diff --git a/app/src/main/java/com/example/slowclock/MainActivity.kt b/app/src/main/java/com/example/slowclock/MainActivity.kt index 6b8c95ed..231a34cb 100644 --- a/app/src/main/java/com/example/slowclock/MainActivity.kt +++ b/app/src/main/java/com/example/slowclock/MainActivity.kt @@ -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 @@ -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 @@ -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) @@ -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 초기화 diff --git a/core/alarm/src/main/java/com/example/slowclock/core/alarm/AlarmNotifications.kt b/core/alarm/src/main/java/com/example/slowclock/core/alarm/AlarmNotifications.kt new file mode 100644 index 00000000..3a75a1eb --- /dev/null +++ b/core/alarm/src/main/java/com/example/slowclock/core/alarm/AlarmNotifications.kt @@ -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() + + 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, + ) = 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 +} 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 d1c13be9..3e542b90 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 @@ -30,6 +30,16 @@ class AlarmScheduler private val scheduledAlarms: ScheduledAlarmRepository, private val snoozedAlarms: SnoozedAlarmRepository, ) { + private var restoredForAppStart = false + + /** 프로세스 첫 앱 진입에서만 복원한다. 회전·재진입은 현재 미룸을 건드리지 않는다. */ + @Synchronized + fun restoreOnAppStart() { + if (restoredForAppStart) return + restoreAll() + restoredForAppStart = true + } + /** * 일정의 다음 회차 알람을 건다. * @@ -37,11 +47,15 @@ class AlarmScheduler * 것이 사라져도(재부팅·강제 종료) 지금 시각에서 다시 세면 그만이기 때문이다. 다음 회차는 * 알람이 울릴 때 [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) @@ -57,12 +71,14 @@ class AlarmScheduler * 내용이 그대로인 일정은 걸린 회차를 지킨다. 앱을 열 때마다 다시 걸면 방금 미뤄 둔 * 알람이 그때마다 사라진다. */ + @Synchronized fun syncWith(schedules: List) { 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) @@ -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 } } /** @@ -91,6 +115,7 @@ class AlarmScheduler * 다시 알림은 AlarmManager 에만 걸려 있어 재부팅과 앱 교체로 사라진다. 미룬 사람은 5분 * 뒤 울릴 것이라고 믿고 있고, 그 믿음이 이 기능의 전부다(#177). */ + @Synchronized fun snooze( baseRequestCode: Int, scheduleId: String, @@ -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) } @@ -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()) @@ -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)) } @@ -177,7 +229,9 @@ class AlarmScheduler * 알람은 근거를 잃는다. 지우지 않으면 계정을 지운 뒤에도 알람이 울리고 재부팅 뒤에도 * 되살아난다(#127). */ + @Synchronized fun cancelAll() { + AlarmNotifications.clear(context) scheduledAlarms.all().forEach { ScheduleAlarmHelper.cancelAlarm(context, it.toSchedule()) } scheduledAlarms.clear() // 마지막 회차가 울리면 일정 장부는 먼저 비워진다. 그 뒤 미룬 알람은 미룸 장부에만 @@ -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 } } /** @@ -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 } } /** @@ -256,6 +326,9 @@ class AlarmScheduler */ fun canUseFullScreenAlarm(): Boolean = context.canUseFullScreenAlarm() + /** 알림을 거부하거나 알람 채널을 끄면 제어할 수 없는 울림을 시작하지 않는다. */ + fun canShowAlarmControls(): Boolean = AlarmNotifications.canShowControls(context) + private fun Schedule.toRecord() = ScheduledAlarm( id = id, 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 bead1bd9..4681f9db 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 @@ -84,10 +84,19 @@ internal object ScheduleAlarmHelper { Log.w(TAG, "정시 알람 권한이 없어 부정확 알람으로 겁니다.") } - scheduleOne(context, alarmManager, schedule, AlarmKind.START, schedule.startTime.toDate().time, now, isFullScreen) - schedule.endTime?.let { end -> - scheduleOne(context, alarmManager, schedule, AlarmKind.END, end.toDate().time, now, isFullScreen) - } + val start = + runCatching { + scheduleOne(context, alarmManager, schedule, AlarmKind.START, schedule.startTime.toDate().time, now, isFullScreen) + } + val end = + runCatching { + schedule.endTime?.let { end -> + scheduleOne(context, alarmManager, schedule, AlarmKind.END, end.toDate().time, now, isFullScreen) + } + } + // 한쪽이 실패해도 다른 알람은 시도한다. 실패를 삼키면 호출자가 성공 장부를 남긴다. + start.exceptionOrNull()?.let { throw it } + end.exceptionOrNull()?.let { throw it } } /** @@ -157,6 +166,7 @@ internal object ScheduleAlarmHelper { Log.d(TAG, "${kind.label} 알람 예약: ${schedule.title} at ${Date(triggerTime)} (requestCode=${slot.requestCode})") } catch (e: Exception) { Log.e(TAG, "${kind.label} 알람 예약 실패: ${e.message}") + throw e } } @@ -233,6 +243,7 @@ internal object ScheduleAlarmHelper { Log.d(TAG, "다시 알림 예약: $title at ${Date(triggerTime)} (자리=$baseRequestCode, 횟수=$snoozeCount)") } catch (e: Exception) { Log.e(TAG, "다시 알림 예약 실패: ${e.message}") + throw e } } 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 806144eb..81572c5c 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 @@ -5,6 +5,7 @@ import android.content.Context import android.content.Intent import android.util.Log import com.example.slowclock.core.alarm.AlarmSchedulerEntryPoint +import java.util.concurrent.Executors /** * 걸어 둔 알람을 다시 거는 자리. @@ -20,18 +21,16 @@ import com.example.slowclock.core.alarm.AlarmSchedulerEntryPoint * - `TIME_SET`(상수 이름은 `ACTION_TIME_CHANGED`): 부팅 직후 시계가 아직 네트워크 시각과 맞지 * 않을 수 있다. 그 상태에서 「지났다」 로 걸러 버린 알람을, 시각이 교정된 뒤 되살린다. * - * `TIMEZONE_CHANGED` 는 받지 않는다. `Schedule.startTime` 은 Firebase `Timestamp`, 곧 절대 - * 시각이고 알람 경로는 그 epoch 밀리초를 그대로 AlarmManager 에 넘긴다. 시간대를 읽는 코드가 - * 한 줄도 없어 다시 계산할 것이 없다. 등록해 두면 뒤에 읽는 사람이 「시각이 현지시각이구나」 로 - * 오해한다. + * `TIMEZONE_CHANGED`는 받지 않는다. 반복 계산은 기기 시간대를 사용하지만 저장 모델은 + * epoch뿐이다. 방송을 추가하는 것만으로 원래 선택한 현지 시각을 보존할 수 없으므로 + * 시간대 정책과 모델을 함께 정하기 전에는 기존 동작을 유지한다. * * 의존성을 `@AndroidEntryPoint` + 필드 주입이 아니라 [AlarmSchedulerEntryPoint] 로 받는다. Kotlin 은 * `BroadcastReceiver.onReceive` 가 추상이라 Hilt 가 요구하는 `super.onReceive` 를 부를 수 없고, * 그 호출을 빼면 주입이 되는지 여부가 부팅 때만 드러난다. 여기서는 받는 자리를 눈으로 볼 수 있게 둔다. * - * `goAsync` 를 쓰지 않는다. 이 경로에는 네트워크도 Firestore 도 없어 동기로 끝난다. `goAsync` - * 를 써도 10초 제한은 그대로이고 `finish()` 를 빠뜨릴 자리만 는다. 그래서 이 수신기에 suspend - * 호출을 들이지 않는 것이 이 파일의 계약이다. + * 장부 읽기와 OS 예약은 goAsync 작업에서 수행해 메인 스레드를 막지 않는다. + * 방송 완료 제한은 여전히 적용되며 finally에서 반드시 finish한다. */ class AlarmRestoreReceiver : BroadcastReceiver() { override fun onReceive( @@ -48,13 +47,20 @@ class AlarmRestoreReceiver : BroadcastReceiver() { } Log.d(TAG, "알람 복원: ${intent.action}") - runCatching { AlarmSchedulerEntryPoint.from(context).restoreAll() }.onFailure { - // 복원에 실패해도 부팅 방송을 물고 죽지 않는다. 앱을 열면 그때 다시 걸린다. - Log.e(TAG, "알람 복원 실패", it) + val pending = goAsync() + executor.execute { + try { + AlarmSchedulerEntryPoint.from(context).restoreAll() + } catch (error: Exception) { + Log.e(TAG, "알람 복원 실패", error) + } finally { + pending.finish() + } } } private companion object { const val TAG = "AlarmRestoreReceiver" + val executor = Executors.newSingleThreadExecutor() } } diff --git a/core/alarm/src/main/java/com/example/slowclock/ui/alarm/AlarmFullScreenActivity.kt b/core/alarm/src/main/java/com/example/slowclock/ui/alarm/AlarmFullScreenActivity.kt index 241f5877..56216c74 100644 --- a/core/alarm/src/main/java/com/example/slowclock/ui/alarm/AlarmFullScreenActivity.kt +++ b/core/alarm/src/main/java/com/example/slowclock/ui/alarm/AlarmFullScreenActivity.kt @@ -16,6 +16,7 @@ import android.widget.TextView import android.window.OnBackInvokedCallback import android.window.OnBackInvokedDispatcher import androidx.core.content.ContextCompat +import com.example.slowclock.core.alarm.AlarmNotifications import com.example.slowclock.core.alarm.R import com.example.slowclock.core.alarm.SnoozePolicy import java.text.SimpleDateFormat @@ -126,8 +127,9 @@ class AlarmFullScreenActivity : Activity() { snoozeButton.text = getString(R.string.alarm_snooze_action, SnoozePolicy.MINUTES) snoozeButton.visibility = View.VISIBLE snoozeButton.setOnClickListener { - startService(AlarmTriggerService.snoozeIntent(this, ringingRequestCode())) - finish() + startService( + AlarmTriggerService.snoozeIntent(this, ringingRequestCode(), intent.getStringExtra(AlarmNotifications.EXTRA_TOKEN)), + ) } } else { // 다 쓴 뒤 눌리지 않는 버튼을 남기면 #129 가 그대로 되풀이된다. 아예 감춘다. @@ -145,7 +147,7 @@ class AlarmFullScreenActivity : Activity() { /** 소리와 진동은 서비스가 낸다. 여기서는 끄라고만 알린다(#122). */ private fun dismissAlarm() { - startService(AlarmTriggerService.dismissIntent(this, ringingRequestCode())) + startService(AlarmTriggerService.dismissIntent(this, ringingRequestCode(), intent.getStringExtra(AlarmNotifications.EXTRA_TOKEN))) } /** 화면이 안 보이면 시계를 멈춘다. 보이지 않는 화면을 1초마다 다시 그릴 이유가 없다(#131). */ 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 a974b8cd..afbd12b4 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 @@ -12,7 +12,9 @@ import android.media.AudioAttributes import android.media.AudioManager import android.media.MediaPlayer import android.media.RingtoneManager +import android.net.Uri import android.os.Build +import android.os.Bundle import android.os.Handler import android.os.IBinder import android.os.Looper @@ -23,11 +25,13 @@ import android.os.VibratorManager import android.util.Log import androidx.core.app.NotificationCompat import androidx.core.app.ServiceCompat +import com.example.slowclock.core.alarm.AlarmNotifications 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 import com.example.slowclock.core.alarm.canUseFullScreenAlarm +import java.util.UUID /** * 알람을 실제로 울리는 서비스. @@ -62,12 +66,12 @@ class AlarmTriggerService : Service() { const val EXTRA_SNOOZE_COUNT = "snoozeCount" private const val TAG = "AlarmTriggerService" - private const val NOTIFICATION_ID = 123 + private const val NOTIFICATION_ID = AlarmNotifications.RINGING_ID // 채널은 한 번 만들면 앱이 소리·중요도를 다시 못 바꾼다. 종전 채널은 오디오 속성이 없어 // 알람이 아니라 알림 소리로 취급됐고, 그 채널이 남은 기기에서는 코드를 고쳐도 그대로다. // 그래서 새 id 를 쓴다(#122). - private const val CHANNEL_ID = "alarm_ringing_v2" + private const val CHANNEL_ID = AlarmNotifications.CHANNEL_ID /** 겹친 알람을 옮겨 두는 채널. 울리지 않고 「이 일정이 지나갔다」 만 보여 준다(#131). */ private const val MISSED_CHANNEL_ID = "alarm_overlapped_v1" @@ -115,15 +119,21 @@ class AlarmTriggerService : Service() { .putExtra(EXTRA_REQUEST_CODE, requestCode) .putExtra(EXTRA_SNOOZE_COUNT, snoozeCount) - /** 울리는 알람을 끈다. */ + /** + * 울리는 알람을 끈다. token은 data에도 넣어 PendingIntent 신원을 회차마다 가른다. + * extras만 바꾸면 FLAG_UPDATE_CURRENT가 옛 버튼까지 다음 회차로 바꿔 버린다. + */ fun dismissIntent( context: Context, requestCode: Int, + token: String? = null, ): Intent = Intent(context, AlarmTriggerService::class.java) .setClass(context, AlarmTriggerService::class.java) .setAction(ACTION_DISMISS) + .setData(token?.let { Uri.parse("slowclock://alarm-command/$it") }) .putExtra(EXTRA_REQUEST_CODE, requestCode) + .putExtra(AlarmNotifications.EXTRA_TOKEN, token) /** * 서비스가 알람 울리기를 끝냈다. 전체 화면이 떠 있으면 이 방송을 받아 함께 닫는다. @@ -136,11 +146,14 @@ class AlarmTriggerService : Service() { fun snoozeIntent( context: Context, requestCode: Int, + token: String? = null, ): Intent = Intent(context, AlarmTriggerService::class.java) .setClass(context, AlarmTriggerService::class.java) .setAction(ACTION_SNOOZE) + .setData(token?.let { Uri.parse("slowclock://alarm-command/$it") }) .putExtra(EXTRA_REQUEST_CODE, requestCode) + .putExtra(AlarmNotifications.EXTRA_TOKEN, token) } /** @@ -149,13 +162,14 @@ class AlarmTriggerService : Service() { * 알림의 다시 알림 버튼은 서비스로 action 만 보낸다. 무엇을 미룰지는 서비스가 이미 알고 * 있으므로 PendingIntent 에 일정 정보를 싣지 않는다. */ - private data class Ringing( + internal data class Ringing( val title: String, val desc: String, val isFullScreen: Boolean, val scheduleId: String, val requestCode: Int, val snoozeCount: Int, + val token: String = UUID.randomUUID().toString(), ) private var ringing: Ringing? = null @@ -177,42 +191,64 @@ class AlarmTriggerService : Service() { ): Int { if (intent?.action == ACTION_DISMISS) { if (isForCurrentRinging(intent)) stopRinging("사용자가 껐다") + if (ringing == null) stopSelf(startId) return START_NOT_STICKY } if (intent?.action == ACTION_SNOOZE) { if (isForCurrentRinging(intent)) snoozeRinging() + if (ringing == null) stopSelf(startId) return START_NOT_STICKY } if (intent?.action == ACTION_SNOOZE_MISSED) { snoozeMissed(intent) + if (ringing == null) stopSelf(startId) + return START_NOT_STICKY + } + + if (intent?.action != ACTION_RING) { + if (ringing == null) stopSelf(startId) + return START_NOT_STICKY + } + // 알림을 거부하면 FGS 승격은 가능하지만 끄기 버튼은 보이지 않는다. + // 사용자가 제어할 수 없는 소리·진동은 시작하지 않는다. + if (!AlarmNotifications.canShowControls(this)) { + stopRinging("알림 조작을 표시할 수 없다") return START_NOT_STICKY } // 이미 울리는 알람이 있으면 그 알림을 별도 자리로 옮겨 남긴다. 알림 자리가 하나뿐이라 // 그냥 두면 뒤에 온 알람이 앞의 것을 화면에서 지우고, 끄기 한 번에 둘 다 꺼진다(#131). - ringing?.let { previous -> keepAsSeparateNotification(previous) } + ringing?.let { previous -> + runCatching { keepAsSeparateNotification(previous) } + .onFailure { Log.e(TAG, "겹친 알람 알림을 남기지 못했다", it) } + } val current = Ringing( - title = intent?.getStringExtra(EXTRA_TITLE) ?: "알람", - desc = intent?.getStringExtra(EXTRA_DESC).orEmpty(), - isFullScreen = intent?.getBooleanExtra(EXTRA_FULL_SCREEN, true) ?: true, - scheduleId = intent?.getStringExtra(EXTRA_SCHEDULE_ID).orEmpty(), - requestCode = intent?.getIntExtra(EXTRA_REQUEST_CODE, 0) ?: 0, - snoozeCount = intent?.getIntExtra(EXTRA_SNOOZE_COUNT, 0) ?: 0, + title = intent.getStringExtra(EXTRA_TITLE) ?: "알람", + desc = intent.getStringExtra(EXTRA_DESC).orEmpty(), + isFullScreen = intent.getBooleanExtra(EXTRA_FULL_SCREEN, true), + scheduleId = intent.getStringExtra(EXTRA_SCHEDULE_ID).orEmpty(), + requestCode = intent.getIntExtra(EXTRA_REQUEST_CODE, 0), + snoozeCount = intent.getIntExtra(EXTRA_SNOOZE_COUNT, 0), ) ringing = current Log.d(TAG, "알람 울림 시작: ${current.title} (미룬 횟수 ${current.snoozeCount})") // 포그라운드로 먼저 올린다. 알림을 띄우기 전에 소리를 내면 시스템이 서비스를 죽일 수 있다. - startForegroundCompat(buildNotification(current)) - - acquireWakeLock() - startSound() - startVibration() + try { + startForegroundCompat(buildNotification(current)) + acquireWakeLock() + startSound() + startVibration() + } catch (error: Exception) { + Log.e(TAG, "알람을 시작하지 못했다", error) + stopRinging("알람 시작 실패") + return START_NOT_STICKY + } stopHandler.removeCallbacks(stopRunnable) stopHandler.postDelayed(stopRunnable, MAX_RINGING_MILLIS) @@ -239,6 +275,8 @@ class AlarmTriggerService : Service() { .setCategory(NotificationCompat.CATEGORY_ALARM) .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) .setAutoCancel(true) + .addExtras(commandExtras(previous)) + .setContentIntent(openAppIntent()) // 밀려난 알람에도 다시 알림을 남긴다. 없으면 그 일정만 다시 울릴 기회가 아예 없어, // 겹친 순간에 뒤에 온 알람이 앞의 것을 통째로 삼킨 셈이 된다(#167). @@ -271,12 +309,14 @@ class AlarmTriggerService : Service() { Intent(this, AlarmTriggerService::class.java) .setClass(this, AlarmTriggerService::class.java) .setAction(ACTION_SNOOZE_MISSED) + .setData(Uri.parse("slowclock://alarm-command/${missed.token}")) .putExtra(EXTRA_TITLE, missed.title) .putExtra(EXTRA_DESC, missed.desc) .putExtra(EXTRA_FULL_SCREEN, missed.isFullScreen) .putExtra(EXTRA_SCHEDULE_ID, missed.scheduleId) .putExtra(EXTRA_REQUEST_CODE, missed.requestCode) .putExtra(EXTRA_SNOOZE_COUNT, missed.snoozeCount) + .putExtra(AlarmNotifications.EXTRA_TOKEN, missed.token) private fun startForegroundCompat(notification: Notification) { // 알람이 울리는 동안 소리를 재생하므로 미디어 재생 타입이다. 종전의 shortService 는 @@ -297,7 +337,7 @@ class AlarmTriggerService : Service() { PendingIntent.getService( this, 0, - dismissIntent(this, current.requestCode), + dismissIntent(this, current.requestCode, current.token), PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, ) @@ -310,6 +350,7 @@ class AlarmTriggerService : Service() { .setPriority(NotificationCompat.PRIORITY_MAX) .setCategory(NotificationCompat.CATEGORY_ALARM) .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + .addExtras(commandExtras(current)) .setOngoing(true) .setAutoCancel(false) // 포그라운드 서비스 알림은 기본적으로 표시가 최대 10초 미뤄진다. 알람에서는 @@ -331,7 +372,7 @@ class AlarmTriggerService : Service() { PendingIntent.getService( this, 2, - snoozeIntent(this, current.requestCode), + snoozeIntent(this, current.requestCode, current.token), PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, ), ) @@ -343,12 +384,14 @@ class AlarmTriggerService : Service() { val fullScreenIntent = Intent(this, AlarmFullScreenActivity::class.java) .setClass(this, AlarmFullScreenActivity::class.java) + .setData(Uri.parse("slowclock://alarm-command/${current.token}")) .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP) .putExtra(EXTRA_TITLE, title) .putExtra(EXTRA_DESC, desc) .putExtra(EXTRA_SNOOZE_COUNT, current.snoozeCount) // 화면이 되돌려 줄 신원. 겹친 알람에서 조작 대상이 어긋나지 않게 한다(#167). .putExtra(EXTRA_REQUEST_CODE, current.requestCode) + .putExtra(AlarmNotifications.EXTRA_TOKEN, current.token) builder.setFullScreenIntent( PendingIntent.getActivity( this, @@ -379,23 +422,32 @@ class AlarmTriggerService : Service() { ?: RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION) ?: return try { - mediaPlayer = - MediaPlayer().apply { - // 알람 스트림으로 재생한다. 이것을 지정하지 않으면 미디어 볼륨을 타서 - // 미디어 소리를 줄여 둔 기기에서는 울려도 들리지 않는다(#122). - setAudioAttributes( - AudioAttributes - .Builder() - .setUsage(AudioAttributes.USAGE_ALARM) - .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) - .build(), - ) - setDataSource(this@AlarmTriggerService, uri) - isLooping = true - prepare() - start() + val player = MediaPlayer() + mediaPlayer = player + player.apply { + // 알람 스트림으로 재생한다. 이것을 지정하지 않으면 미디어 볼륨을 타서 + // 미디어 소리를 줄여 둔 기기에서는 울려도 들리지 않는다(#122). + setAudioAttributes( + AudioAttributes + .Builder() + .setUsage(AudioAttributes.USAGE_ALARM) + .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) + .build(), + ) + setDataSource(this@AlarmTriggerService, uri) + isLooping = true + setOnPreparedListener { ready -> + if (mediaPlayer === ready && ringing != null) ready.start() else ready.release() + } + setOnErrorListener { failed, _, _ -> + if (mediaPlayer === failed) mediaPlayer = null + failed.release() + true } + prepareAsync() + } } catch (e: Exception) { + stopSound() Log.e(TAG, "알람 소리 재생 실패: ${e.message}") } } @@ -447,13 +499,16 @@ class AlarmTriggerService : Service() { * 없는 요청을 그대로 받으면, 사용자가 화면에서 본 일정이 아니라 마지막에 도착한 알람이 * 미뤄지거나 꺼진다(#167). * - * 자리 번호가 없는 옛 알림에서 온 요청은 지금 울리는 것에 대한 것으로 본다. 그러지 않으면 - * 그 버튼이 아무 일도 하지 않는 버튼이 된다. + * 회차 토큰이 없는 옛 요청은 현재 알람을 조작하지 못한다. */ private fun isForCurrentRinging(intent: Intent): Boolean { val current = ringing ?: return false val requested = intent.getIntExtra(EXTRA_REQUEST_CODE, UNKNOWN_REQUEST_CODE) - if (requested == UNKNOWN_REQUEST_CODE || requested == current.requestCode) return true + val token = intent.getStringExtra(AlarmNotifications.EXTRA_TOKEN) + if (requested == current.requestCode && token == current.token && AlarmNotifications.isUsableToken(token)) { + // 스와이프의 deleteIntent는 OS가 알림을 지운 뒤 도착한다. 끄기는 토큰만 확인한다. + if (intent.action == ACTION_DISMISS || AlarmNotifications.isCurrent(this, NOTIFICATION_ID, token)) return true + } Log.w(TAG, "지금 울리는 알람이 아니라 무시한다: 요청=$requested 현재=${current.requestCode}") return false } @@ -468,16 +523,29 @@ class AlarmTriggerService : Service() { val snoozeCount = intent.getIntExtra(EXTRA_SNOOZE_COUNT, 0) if (!SnoozePolicy.canSnooze(snoozeCount)) return val requestCode = intent.getIntExtra(EXTRA_REQUEST_CODE, 0) - snoozeThrough( - baseRequestCode = requestCode, - scheduleId = intent.getStringExtra(EXTRA_SCHEDULE_ID).orEmpty(), - title = intent.getStringExtra(EXTRA_TITLE) ?: "알람", - desc = intent.getStringExtra(EXTRA_DESC).orEmpty(), - isFullScreen = intent.getBooleanExtra(EXTRA_FULL_SCREEN, true), - snoozeCount = snoozeCount + 1, - ) - val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - runCatching { manager.cancel(MISSED_NOTIFICATION_ID_BASE + requestCode) } + if (!AlarmNotifications.isCurrent( + this, + MISSED_NOTIFICATION_ID_BASE + requestCode, + intent.getStringExtra(AlarmNotifications.EXTRA_TOKEN), + ) + ) { + return + } + val scheduled = + snoozeThrough( + baseRequestCode = requestCode, + scheduleId = intent.getStringExtra(EXTRA_SCHEDULE_ID).orEmpty(), + title = intent.getStringExtra(EXTRA_TITLE) ?: "알람", + desc = intent.getStringExtra(EXTRA_DESC).orEmpty(), + isFullScreen = intent.getBooleanExtra(EXTRA_FULL_SCREEN, true), + snoozeCount = snoozeCount + 1, + notificationId = MISSED_NOTIFICATION_ID_BASE + requestCode, + token = intent.getStringExtra(AlarmNotifications.EXTRA_TOKEN).orEmpty(), + ) + if (scheduled) { + val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + manager.cancel(MISSED_NOTIFICATION_ID_BASE + requestCode) + } } /** @@ -494,15 +562,18 @@ class AlarmTriggerService : Service() { stopRinging("다시 알림을 걸 수 없다") return } - snoozeThrough( - baseRequestCode = current.requestCode, - scheduleId = current.scheduleId, - title = current.title, - desc = current.desc, - isFullScreen = current.isFullScreen, - snoozeCount = current.snoozeCount + 1, - ) - stopRinging("다시 알림") + val scheduled = + snoozeThrough( + baseRequestCode = current.requestCode, + scheduleId = current.scheduleId, + title = current.title, + desc = current.desc, + isFullScreen = current.isFullScreen, + snoozeCount = current.snoozeCount + 1, + notificationId = NOTIFICATION_ID, + token = current.token, + ) + if (scheduled) stopRinging("다시 알림") } /** @@ -511,8 +582,7 @@ class AlarmTriggerService : Service() { * 여기서 [ScheduleAlarmHelper] 를 바로 부르면 예약만 되고 기기 안 장부에는 아무것도 남지 * 않는다. 그러면 재부팅이나 앱 교체로 미뤄 둔 알람이 조용히 사라진다(#177). * - * 스케줄러를 못 꺼내는 상황이라도 미루기 자체는 되어야 한다. 장부 없이라도 거는 것이 - * 아무 일도 일어나지 않는 버튼보다 낫다. + * 예약 실패 때 현재 울림을 유지한다. 성공을 확인하기 전에는 조작 알림도 지우지 않는다. */ private fun snoozeThrough( baseRequestCode: Int, @@ -521,33 +591,42 @@ class AlarmTriggerService : Service() { 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, + notificationId: Int, + token: String, + ): Boolean = + try { + AlarmSchedulerEntryPoint.from(this).snoozeFromNotification( baseRequestCode = baseRequestCode, scheduleId = scheduleId, title = title, desc = desc, isFullScreen = isFullScreen, snoozeCount = snoozeCount, - triggerTime = SnoozePolicy.nextTriggerAt(System.currentTimeMillis()), + notificationId = notificationId, + token = token, ) + } catch (failure: Exception) { + Log.e(TAG, "다시 알림 예약 실패, 현재 알람을 유지한다", failure) + android.widget.Toast + .makeText(this, R.string.alarm_snooze_failed, android.widget.Toast.LENGTH_LONG) + .show() + false + } + + private fun commandExtras(current: Ringing) = + Bundle().apply { + putString(AlarmNotifications.EXTRA_TOKEN, current.token) + putString(AlarmNotifications.EXTRA_SCHEDULE, current.scheduleId) + } + + private fun openAppIntent(): PendingIntent? = + packageManager.getLaunchIntentForPackage(packageName)?.let { + PendingIntent.getActivity(this, 3, it, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) } - } private fun stopRinging(reason: String) { Log.d(TAG, "알람 울림 종료: $reason") + ringing?.token?.let(AlarmNotifications::revoke) ringing = null stopHandler.removeCallbacks(stopRunnable) stopSound() @@ -562,11 +641,9 @@ class AlarmTriggerService : Service() { } private fun stopSound() { - mediaPlayer?.runCatching { - if (isPlaying) stop() - release() - } + val player = mediaPlayer mediaPlayer = null + player?.runCatching { release() } } private fun createChannel() { @@ -606,6 +683,8 @@ class AlarmTriggerService : Service() { stopSound() vibrator?.cancel() releaseWakeLock() + ringing = null + sendBroadcast(Intent(ACTION_RINGING_FINISHED).setPackage(packageName)) Log.d(TAG, "알람 서비스 종료") } diff --git a/core/alarm/src/main/res/values/strings.xml b/core/alarm/src/main/res/values/strings.xml index bdfedfe8..6c88a463 100644 --- a/core/alarm/src/main/res/values/strings.xml +++ b/core/alarm/src/main/res/values/strings.xml @@ -7,4 +7,5 @@ 다시 알림 (%1$d분) 알람 끄기 알람 + 다시 알림을 예약하지 못했습니다. 알람은 계속 울립니다. 다시 시도하거나 알람을 꺼 주세요. diff --git a/core/alarm/src/test/java/com/example/slowclock/core/alarm/AlarmNotificationsTest.kt b/core/alarm/src/test/java/com/example/slowclock/core/alarm/AlarmNotificationsTest.kt new file mode 100644 index 00000000..a6fd008d --- /dev/null +++ b/core/alarm/src/test/java/com/example/slowclock/core/alarm/AlarmNotificationsTest.kt @@ -0,0 +1,88 @@ +package com.example.slowclock.core.alarm + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.Context +import android.os.Bundle +import android.service.notification.StatusBarNotification +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.util.UUID + +class AlarmNotificationsTest { + private val context = mockk(relaxed = true) + private val manager = mockk(relaxed = true) + + init { + every { context.getSystemService(Context.NOTIFICATION_SERVICE) } returns manager + } + + @Test + fun `앱 알림 거부와 알람 채널 차단은 모두 울림 조작 불가다`() { + every { manager.areNotificationsEnabled() } returns false + assertFalse(AlarmNotifications.canShowControls(context)) + every { manager.areNotificationsEnabled() } returns true + val channel = mockk() + every { manager.getNotificationChannel(AlarmNotifications.CHANNEL_ID) } returns channel + every { channel.importance } returns NotificationManager.IMPORTANCE_NONE + assertFalse(AlarmNotifications.canShowControls(context)) + every { channel.importance } returns NotificationManager.IMPORTANCE_HIGH + assertTrue(AlarmNotifications.canShowControls(context)) + } + + @Test + fun `취소가 OS 알림목록에 늦게 반영돼도 토큰은 즉시 무효다`() { + val token = UUID.randomUUID().toString() + every { manager.activeNotifications } returns arrayOf(notification("deleted", 1042, token)) + assertTrue(AlarmNotifications.isCurrent(context, 1042, token)) + + AlarmNotifications.invalidate(context, "deleted") + + // 비동기 cancel 완료 전처럼 activeNotifications가 여전히 옛 알림을 반환한다. + assertFalse(AlarmNotifications.isCurrent(context, 1042, token)) + verify { manager.cancel(null, 1042) } + } + + @Test + fun `같은 알림번호의 새로운 회차는 예전 토큰으로 조작할 수 없다`() { + every { manager.activeNotifications } returns arrayOf(notification("s1", 1042, "new")) + assertFalse(AlarmNotifications.isCurrent(context, 1042, "old")) + assertFalse(AlarmNotifications.isCurrent(context, 1043, "new")) + assertTrue(AlarmNotifications.isCurrent(context, 1042, "new")) + } + + @Test + fun `서버에서 지운 마지막 단발 일정의 겹침 알림도 무효화한다`() { + val removedToken = UUID.randomUUID().toString() + val retainedToken = UUID.randomUUID().toString() + every { manager.activeNotifications } returns + arrayOf( + notification("gone", 1042, removedToken), + notification("remaining", 1043, retainedToken), + ) + AlarmNotifications.retainSchedules(context, setOf("remaining")) + assertFalse(AlarmNotifications.isCurrent(context, 1042, removedToken)) + assertTrue(AlarmNotifications.isCurrent(context, 1043, retainedToken)) + } + + private fun notification( + scheduleId: String, + id: Int, + token: String, + ): StatusBarNotification { + val extras = mockk() + every { extras.getString(AlarmNotifications.EXTRA_TOKEN) } returns token + every { extras.getString(AlarmNotifications.EXTRA_SCHEDULE) } returns scheduleId + val notification = Notification().apply { this.extras = extras } + return mockk().also { + every { it.id } returns id + every { it.tag } returns null + every { it.notification } returns notification + } + } +} 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 index 3a920d86..2a82e2e4 100644 --- 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 @@ -66,7 +66,10 @@ class AlarmSchedulerSnoozeTest { snoozed.remove(firstArg()) Unit } - mockkObject(ScheduleAlarmHelper) + mockkObject(ScheduleAlarmHelper, AlarmNotifications) + every { AlarmNotifications.invalidate(any(), any()) } returns Unit + every { AlarmNotifications.clear(any()) } returns Unit + every { AlarmNotifications.retainSchedules(any(), any()) } returns Unit every { ScheduleAlarmHelper.scheduleAlarm(context, any(), any()) } returns Unit every { ScheduleAlarmHelper.cancelAlarm(context, any()) } returns Unit every { ScheduleAlarmHelper.cancelSnooze(context, any()) } returns Unit @@ -75,7 +78,7 @@ class AlarmSchedulerSnoozeTest { @After fun tearDown() { - unmockkObject(ScheduleAlarmHelper) + unmockkObject(ScheduleAlarmHelper, AlarmNotifications) } @Test @@ -149,6 +152,68 @@ class AlarmSchedulerSnoozeTest { verify(exactly = 1) { ScheduleAlarmHelper.cancelSnooze(context, snooze.baseRequestCode) } } + @Test + fun `OS 예약 실패는 성공 장부로 남지 않고 다음 앱 복원이 같은 회차를 재시도한다`() { + val future = record.copy(startMillis = now + 3_600_000, bookedStartMillis = null) + scheduled.clear() + every { ScheduleAlarmHelper.scheduleAlarm(context, any(), any()) } throws IllegalStateException("quota") + + org.junit.Assert.assertThrows(IllegalStateException::class.java) { scheduler.schedule(scheduleOf(future)) } + assertEquals(future, scheduled[future.id]) + + every { ScheduleAlarmHelper.scheduleAlarm(context, any(), any()) } returns Unit + scheduler.restoreOnAppStart() + assertEquals(future.startMillis, scheduled[future.id]?.bookedStartMillis) + } + + @Test + fun `최초 앱 복원은 booked 장부도 다시 걸고 두 번째 진입에서는 미룸을 보존한다`() { + val future = record.copy(startMillis = now + 3_600_000, bookedStartMillis = now + 3_600_000) + scheduled[future.id] = future + + scheduler.restoreOnAppStart() + scheduler.restoreOnAppStart() + + verify(exactly = 1) { ScheduleAlarmHelper.scheduleAlarm(context, any(), any()) } + assertEquals(snooze, snoozed[snooze.baseRequestCode]) + } + + @Test + fun `한 일정 복원이 실패해도 나머지 일정과 미룸 복원은 진행한다`() { + val second = record.copy(id = "s2") + scheduled[second.id] = second + every { ScheduleAlarmHelper.scheduleAlarm(context, match { it.id == record.id }, any()) } throws IllegalStateException("quota") + + org.junit.Assert.assertThrows(IllegalStateException::class.java) { scheduler.restoreAll() } + + assertEquals(null, scheduled[record.id]?.bookedStartMillis) + assertTrue(scheduled[second.id]?.bookedStartMillis != null) + verify(exactly = 1) { ScheduleAlarmHelper.scheduleSnooze(context, any(), any(), any(), any(), any(), any(), any()) } + } + + @Test + fun `미룸 예약 실패는 성공 기록을 남기지 않는다`() { + snoozed.clear() + every { ScheduleAlarmHelper.scheduleSnooze(context, any(), any(), any(), any(), any(), any(), any()) } throws + IllegalStateException("quota") + + org.junit.Assert.assertThrows(IllegalStateException::class.java) { + scheduler.snooze(snooze.baseRequestCode, record.id, "약", "", true, 1) + } + + assertTrue(snoozed.isEmpty()) + } + + @Test + fun `삭제된 알림의 늦은 미룸은 예약과 장부를 만들지 않는다`() { + every { AlarmNotifications.isCurrent(context, any(), any()) } returns false + + val accepted = scheduler.snoozeFromNotification(42, "deleted", "약", "", true, 1, 1042, "old") + + org.junit.Assert.assertFalse(accepted) + verify(exactly = 0) { ScheduleAlarmHelper.scheduleSnooze(context, any(), any(), any(), any(), any(), any(), any()) } + } + private fun scheduleOf(record: ScheduledAlarm) = Schedule( id = record.id, 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 index 7347ed40..754cd871 100644 --- 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 @@ -26,7 +26,10 @@ class AlarmSchedulerTest { every { scheduled.all() } returns emptyList() every { snoozed.all() } returns snoozes val pending = snoozes.map { it.baseRequestCode }.toMutableSet() - mockkObject(ScheduleAlarmHelper) + mockkObject(ScheduleAlarmHelper, AlarmNotifications) + every { AlarmNotifications.invalidate(any(), any()) } returns Unit + every { AlarmNotifications.clear(any()) } returns Unit + every { AlarmNotifications.retainSchedules(any(), any()) } returns Unit try { every { ScheduleAlarmHelper.cancelSnooze(context, any()) } answers { pending.remove(secondArg()) @@ -38,7 +41,7 @@ class AlarmSchedulerTest { assertTrue("로그아웃 뒤에도 미룸 예약이 남아 있다", pending.isEmpty()) verify(exactly = 1) { snoozed.clear() } } finally { - unmockkObject(ScheduleAlarmHelper) + unmockkObject(ScheduleAlarmHelper, AlarmNotifications) } } } diff --git a/core/alarm/src/test/java/com/example/slowclock/ui/alarm/AlarmTriggerServiceTest.kt b/core/alarm/src/test/java/com/example/slowclock/ui/alarm/AlarmTriggerServiceTest.kt new file mode 100644 index 00000000..e2657068 --- /dev/null +++ b/core/alarm/src/test/java/com/example/slowclock/ui/alarm/AlarmTriggerServiceTest.kt @@ -0,0 +1,139 @@ +package com.example.slowclock.ui.alarm + +import android.app.Notification +import android.content.Intent +import com.example.slowclock.core.alarm.AlarmNotifications +import com.example.slowclock.core.alarm.AlarmScheduler +import com.example.slowclock.core.alarm.AlarmSchedulerEntryPoint +import io.mockk.every +import io.mockk.justRun +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.spyk +import io.mockk.unmockkAll +import io.mockk.verify +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test + +/** 플랫폼 자원 경계만 대체하고 실제 onStartCommand와 미룸 분기를 연결한다. */ +class AlarmTriggerServiceTest { + private lateinit var service: AlarmTriggerService + private val scheduler = mockk() + + @Before + fun setUp() { + mockkObject(AlarmNotifications, AlarmSchedulerEntryPoint.Companion) + every { AlarmNotifications.canShowControls(any()) } returns true + every { AlarmNotifications.isCurrent(any(), any(), any()) } returns true + every { AlarmNotifications.isUsableToken(any()) } returns true + justRun { AlarmNotifications.revoke(any()) } + every { AlarmSchedulerEntryPoint.from(any()) } returns scheduler + service = spyk(AlarmTriggerService(), recordPrivateCalls = true) + every { service["buildNotification"](any()) } returns mockk() + justRun { service["startForegroundCompat"](any()) } + justRun { service["acquireWakeLock"]() } + justRun { service["startSound"]() } + justRun { service["startVibration"]() } + } + + @After + fun tearDown() = unmockkAll() + + @Test + fun `알림 거부 때 foreground와 소리와 진동을 시작하지 않는다`() { + every { AlarmNotifications.canShowControls(any()) } returns false + service.onStartCommand(ring(0), 0, 1) + verify(exactly = 0) { service["startForegroundCompat"](any()) } + verify(exactly = 0) { service["startSound"]() } + verify { service.stopSelf() } + } + + @Test + fun `승격 예외가 나면 소리와 자원 없이 종료한다`() { + every { service["startForegroundCompat"](any()) } throws SecurityException("promotion") + service.onStartCommand(ring(0), 0, 1) + verify(exactly = 0) { service["startSound"]() } + verify(exactly = 0) { service["acquireWakeLock"]() } + verify { service.stopSelf() } + } + + @Test + fun `빈 서비스의 늦은 끄기와 미룸 요청은 서비스를 남기지 않는다`() { + service.onStartCommand(action(AlarmTriggerService.ACTION_DISMISS), 0, 1) + service.onStartCommand(action(AlarmTriggerService.ACTION_SNOOZE), 0, 2) + service.onStartCommand(null, 0, 3) + verify { service.stopSelf(1) } + verify { service.stopSelf(2) } + verify { service.stopSelf(3) } + verify(exactly = 0) { service["startSound"]() } + } + + @Test + fun `미룸 실패는 현재 울림을 남기고 성공한 다음 요청만 멈춘다`() { + every { scheduler.snoozeFromNotification(any(), any(), any(), any(), any(), any(), any(), any()) } returns false + service.onStartCommand(ring(0), 0, 1) + val token = currentToken() + service.onStartCommand(action(AlarmTriggerService.ACTION_SNOOZE, token), 0, 2) + verify(exactly = 0) { service.stopSelf() } + assertEquals(token, currentToken()) + every { scheduler.snoozeFromNotification(any(), any(), any(), any(), any(), any(), any(), any()) } returns true + service.onStartCommand(action(AlarmTriggerService.ACTION_SNOOZE, token), 0, 3) + verify(exactly = 1) { service.stopSelf() } + } + + @Test + fun `실제 서비스 재수신은 0에서1에서2로 미루고 세번째 예약은 하지 않는다`() { + every { scheduler.snoozeFromNotification(any(), any(), any(), any(), any(), any(), any(), any()) } returns true + for (count in 0..2) { + service.onStartCommand(ring(count), 0, count * 2 + 1) + service.onStartCommand(action(AlarmTriggerService.ACTION_SNOOZE, currentToken()), 0, count * 2 + 2) + } + verify(exactly = 1) { scheduler.snoozeFromNotification(42, "s1", "약", "", true, 1, 123, any()) } + verify(exactly = 1) { scheduler.snoozeFromNotification(42, "s1", "약", "", true, 2, 123, any()) } + verify(exactly = 0) { scheduler.snoozeFromNotification(any(), any(), any(), any(), any(), 3, any(), any()) } + } + + @Test + fun `알림 스와이프 뒤 deleteIntent는 OS목록에서 사라진 현재 알람도 끈다`() { + service.onStartCommand(ring(0), 0, 1) + val token = currentToken() + every { AlarmNotifications.isCurrent(any(), any(), any()) } returns false + service.onStartCommand(action(AlarmTriggerService.ACTION_DISMISS, token), 0, 2) + verify(exactly = 1) { service.stopSelf() } + } + + private fun ring(count: Int): Intent = + mockk(relaxed = true).also { + every { it.action } returns AlarmTriggerService.ACTION_RING + every { it.getStringExtra(AlarmTriggerService.EXTRA_TITLE) } returns "약" + every { it.getStringExtra(AlarmTriggerService.EXTRA_DESC) } returns "" + every { it.getStringExtra(AlarmTriggerService.EXTRA_SCHEDULE_ID) } returns "s1" + every { it.getBooleanExtra(AlarmTriggerService.EXTRA_FULL_SCREEN, true) } returns true + every { it.getIntExtra(AlarmTriggerService.EXTRA_REQUEST_CODE, 0) } returns 42 + every { it.getIntExtra(AlarmTriggerService.EXTRA_SNOOZE_COUNT, 0) } returns count + } + + private fun action( + action: String, + token: String? = null, + ): Intent = + mockk(relaxed = true).also { + every { it.action } returns action + every { it.getIntExtra(AlarmTriggerService.EXTRA_REQUEST_CODE, Int.MIN_VALUE) } returns 42 + every { it.getStringExtra(AlarmNotifications.EXTRA_TOKEN) } returns token + } + + private fun currentToken(): String { + val current = + AlarmTriggerService::class.java + .getDeclaredField("ringing") + .apply { isAccessible = true } + .get(service) + return current.javaClass + .getDeclaredField("token") + .apply { isAccessible = true } + .get(current) as String + } +} diff --git a/feature/addschedule/src/main/java/com/example/slowclock/ui/addschedule/AddScheduleContract.kt b/feature/addschedule/src/main/java/com/example/slowclock/ui/addschedule/AddScheduleContract.kt index eb5813f3..463f925c 100644 --- a/feature/addschedule/src/main/java/com/example/slowclock/ui/addschedule/AddScheduleContract.kt +++ b/feature/addschedule/src/main/java/com/example/slowclock/ui/addschedule/AddScheduleContract.kt @@ -70,10 +70,12 @@ data class AddScheduleUiState( val isEditMode: Boolean = false, val editingSchedule: Schedule? = null, val editScheduleId: String? = null, + /** 서버 저장은 완료됐다. 재시도는 이 ID의 로컬 알람만 예약한다. */ + val pendingAlarmSchedule: Schedule? = null, ) : UiState { val hasValidTimeInput: Boolean get() = startTimeInput.isValid && (endTimeInput.isEmpty || endTimeInput.isValid) val canSave: Boolean get() = - title.isNotBlank() && !isLoading && !isSaved && hasValidTimeInput && + title.isNotBlank() && !isLoading && !isSaved && pendingAlarmSchedule == null && hasValidTimeInput && (!isEditMode || editingSchedule != null) fun canApplyRecommendedTitle(scheduleId: String?): Boolean = @@ -124,6 +126,10 @@ sealed interface AddScheduleReducerEvent : ReducerEvent { data object Saved : AddScheduleReducerEvent + data class AlarmFailed( + val schedule: Schedule, + ) : AddScheduleReducerEvent + data class Failed( val error: AppError, val canRetry: Boolean, diff --git a/feature/addschedule/src/main/java/com/example/slowclock/ui/addschedule/AddScheduleScreen.kt b/feature/addschedule/src/main/java/com/example/slowclock/ui/addschedule/AddScheduleScreen.kt index 79142f7e..701af6ba 100644 --- a/feature/addschedule/src/main/java/com/example/slowclock/ui/addschedule/AddScheduleScreen.kt +++ b/feature/addschedule/src/main/java/com/example/slowclock/ui/addschedule/AddScheduleScreen.kt @@ -37,9 +37,11 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.example.slowclock.feature.addschedule.R import com.example.slowclock.ui.addschedule.components.RecommendationPlaceholder import com.example.slowclock.ui.addschedule.components.RecurringSection import com.example.slowclock.ui.addschedule.components.TimePickerSection @@ -206,22 +208,39 @@ internal fun AddScheduleContent( style = MaterialTheme.typography.bodyLarge, ) - Row( - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - OutlinedButton( - onClick = { onIntent(AddScheduleIntent.ConsumeError) }, - modifier = Modifier.weight(1f), - ) { - Text("닫기") - } - - if (state.canRetry) { + if (state.pendingAlarmSchedule != null) { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { Button( onClick = { onIntent(AddScheduleIntent.Retry) }, + modifier = Modifier.fillMaxWidth().heightIn(min = 56.dp), + ) { + Text(stringResource(R.string.retry_saved_alarm)) + } + OutlinedButton( + onClick = { onIntent(AddScheduleIntent.ConsumeError) }, + modifier = Modifier.fillMaxWidth().heightIn(min = 56.dp), + ) { + Text(stringResource(R.string.return_to_saved_schedules)) + } + } + } else { + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + OutlinedButton( + onClick = { onIntent(AddScheduleIntent.ConsumeError) }, modifier = Modifier.weight(1f), ) { - Text("다시 시도") + Text("닫기") + } + + if (state.canRetry) { + Button( + onClick = { onIntent(AddScheduleIntent.Retry) }, + modifier = Modifier.weight(1f), + ) { + Text("다시 시도") + } } } } diff --git a/feature/addschedule/src/main/java/com/example/slowclock/ui/addschedule/AddScheduleViewModel.kt b/feature/addschedule/src/main/java/com/example/slowclock/ui/addschedule/AddScheduleViewModel.kt index 25ac7f92..b53e09f1 100644 --- a/feature/addschedule/src/main/java/com/example/slowclock/ui/addschedule/AddScheduleViewModel.kt +++ b/feature/addschedule/src/main/java/com/example/slowclock/ui/addschedule/AddScheduleViewModel.kt @@ -30,6 +30,11 @@ class AddScheduleViewModel override fun onIntent(intent: AddScheduleIntent) { // 폼이 읽히거나 저장되는 중의 중복 입력·저장은 진행 중인 작업을 바꾸지 않는다. if (currentState.isLoading && (intent !is AddScheduleIntent.LoadForEdit || editLoadJob?.isActive != true)) return + if (currentState.pendingAlarmSchedule != null && + intent != AddScheduleIntent.Retry && intent != AddScheduleIntent.ConsumeError + ) { + return + } when (intent) { is AddScheduleIntent.UpdateTitle -> { dispatch(AddScheduleReducerEvent.TitleChanged(intent.value)) @@ -70,7 +75,9 @@ class AddScheduleViewModel AddScheduleIntent.Retry -> { dispatch(AddScheduleReducerEvent.ErrorConsumed) val state = currentState - if (state.isEditMode && state.editingSchedule == null) { + if (state.pendingAlarmSchedule != null) { + reserveSavedAlarm(state.pendingAlarmSchedule) + } else if (state.isEditMode && state.editingSchedule == null) { state.editScheduleId?.let(::loadForEdit) } else { save() @@ -78,7 +85,11 @@ class AddScheduleViewModel } AddScheduleIntent.ConsumeError -> { - dispatch(AddScheduleReducerEvent.ErrorConsumed) + if (currentState.pendingAlarmSchedule != null) { + dispatch(AddScheduleReducerEvent.Saved) + } else { + dispatch(AddScheduleReducerEvent.ErrorConsumed) + } } AddScheduleIntent.ConsumeSaved -> { @@ -167,7 +178,16 @@ class AddScheduleViewModel } AddScheduleReducerEvent.Saved -> { - state.copy(isLoading = false, isSaved = true) + state.copy(isLoading = false, isSaved = true, pendingAlarmSchedule = null, error = null) + } + + is AddScheduleReducerEvent.AlarmFailed -> { + state.copy( + isLoading = false, + pendingAlarmSchedule = event.schedule, + error = AppError.GeneralError("일정은 저장됐지만 알람을 예약하지 못했습니다. 다시 시도하면 알람만 예약합니다."), + canRetry = true, + ) } is AddScheduleReducerEvent.Failed -> { @@ -183,6 +203,17 @@ class AddScheduleViewModel } } + private fun reserveSavedAlarm(schedule: Schedule) { + dispatch(AddScheduleReducerEvent.Saving) + try { + alarmScheduler.schedule(schedule) + dispatch(AddScheduleReducerEvent.Saved) + } catch (error: Exception) { + Log.e(TAG, "일정 저장 후 알람 예약 실패", error) + dispatch(AddScheduleReducerEvent.AlarmFailed(schedule)) + } + } + private fun loadForEdit(scheduleId: String) { if (currentState.editingSchedule?.id == scheduleId || (currentState.editScheduleId == scheduleId && editLoadJob?.isActive == true) @@ -235,9 +266,7 @@ class AddScheduleViewModel when (result) { is ScheduleRepository.ScheduleResult.Success -> { // 새 일정은 Firestore 가 준 ID 로 알람을 건다. 빈 ID 로 걸면 모든 새 일정이 같은 요청 코드를 쓴다. - runCatching { alarmScheduler.schedule(schedule.copy(id = result.data)) } - .onFailure { Log.e(TAG, "알람 예약 실패", it) } - dispatch(AddScheduleReducerEvent.Saved) + reserveSavedAlarm(schedule.copy(id = result.data)) } is ScheduleRepository.ScheduleResult.Error -> { diff --git a/feature/addschedule/src/main/res/values/strings.xml b/feature/addschedule/src/main/res/values/strings.xml new file mode 100644 index 00000000..a4986150 --- /dev/null +++ b/feature/addschedule/src/main/res/values/strings.xml @@ -0,0 +1,5 @@ + + + 알람 다시 예약 + 목록으로 돌아가기 + diff --git a/feature/addschedule/src/test/java/com/example/slowclock/ui/addschedule/AddScheduleViewModelTest.kt b/feature/addschedule/src/test/java/com/example/slowclock/ui/addschedule/AddScheduleViewModelTest.kt index 38794d71..0dcf92aa 100644 --- a/feature/addschedule/src/test/java/com/example/slowclock/ui/addschedule/AddScheduleViewModelTest.kt +++ b/feature/addschedule/src/test/java/com/example/slowclock/ui/addschedule/AddScheduleViewModelTest.kt @@ -7,6 +7,7 @@ import com.example.slowclock.util.AppError import com.google.firebase.Timestamp import io.mockk.coEvery import io.mockk.coVerify +import io.mockk.every import io.mockk.justRun import io.mockk.mockk import io.mockk.slot @@ -43,6 +44,51 @@ class AddScheduleViewModelTest { Dispatchers.resetMain() } + @Test + fun `서버 저장 후 알람 실패 재시도는 같은 ID를 예약하고 서버 저장을 반복하지 않는다`() = + runTest { + coEvery { scheduleRepository.addSchedule(any()) } returns ScheduleRepository.ScheduleResult.Success("saved-id") + every { alarmScheduler.schedule(any()) } throws IllegalStateException("quota") + val viewModel = createViewModel() + viewModel.onIntent(AddScheduleIntent.UpdateTitle("약")) + viewModel.onIntent(AddScheduleIntent.Save) + assertEquals( + "saved-id", + viewModel.uiState.value.pendingAlarmSchedule + ?.id, + ) + assertFalse(viewModel.uiState.value.isSaved) + assertFalse(viewModel.uiState.value.canSave) + assertTrue( + viewModel.uiState.value.error!! + .message + .contains("일정은 저장됐지만"), + ) + + viewModel.onIntent(AddScheduleIntent.Save) + viewModel.onIntent(AddScheduleIntent.UpdateTitle("변경되지 않아야 함")) + justRun { alarmScheduler.schedule(any()) } + viewModel.onIntent(AddScheduleIntent.Retry) + + assertTrue(viewModel.uiState.value.isSaved) + assertNull(viewModel.uiState.value.pendingAlarmSchedule) + coVerify(exactly = 1) { scheduleRepository.addSchedule(any()) } + verify(exactly = 2) { alarmScheduler.schedule(match { it.id == "saved-id" && it.title == "약" }) } + } + + @Test + fun `알람 실패 안내를 닫으면 저장된 일정으로 돌아가고 재저장하지 않는다`() = + runTest { + coEvery { scheduleRepository.addSchedule(any()) } returns ScheduleRepository.ScheduleResult.Success("saved-id") + every { alarmScheduler.schedule(any()) } throws IllegalStateException("quota") + val viewModel = createViewModel() + viewModel.onIntent(AddScheduleIntent.UpdateTitle("약")) + viewModel.onIntent(AddScheduleIntent.Save) + viewModel.onIntent(AddScheduleIntent.ConsumeError) + assertTrue(viewModel.uiState.value.isSaved) + coVerify(exactly = 1) { scheduleRepository.addSchedule(any()) } + } + private fun createViewModel() = AddScheduleViewModel(scheduleRepository, alarmScheduler) @Test diff --git a/feature/main/src/main/java/com/example/slowclock/ui/main/MainContract.kt b/feature/main/src/main/java/com/example/slowclock/ui/main/MainContract.kt index acaf6795..80c202bf 100644 --- a/feature/main/src/main/java/com/example/slowclock/ui/main/MainContract.kt +++ b/feature/main/src/main/java/com/example/slowclock/ui/main/MainContract.kt @@ -45,6 +45,10 @@ sealed interface MainIntent : MviIntent { data object ConsumeExactAlarmSettingsRequest : MainIntent + data object OpenNotificationSettings : MainIntent + + data object ConsumeNotificationSettingsRequest : MainIntent + data object ExactAlarmSettingsUnavailable : MainIntent data object ConsumeUserMessage : MainIntent @@ -81,6 +85,8 @@ data class MainUiState( val showExactAlarmNotice: Boolean = false, /** null 이 아니면 화면이 시스템 설정을 한 번 연다. */ val openExactAlarmSettings: Unit? = null, + val alarmControlsAvailable: Boolean = true, + val openNotificationSettings: Unit? = null, val userMessage: String? = null, ) : UiState @@ -164,6 +170,14 @@ sealed interface MainReducerEvent : ReducerEvent { data object ExactAlarmSettingsRequestConsumed : MainReducerEvent + data class AlarmControlsChecked( + val available: Boolean, + ) : MainReducerEvent + + data object NotificationSettingsRequested : MainReducerEvent + + data object NotificationSettingsRequestConsumed : MainReducerEvent + data object ExactAlarmSettingsFailed : MainReducerEvent data object UserMessageConsumed : MainReducerEvent diff --git a/feature/main/src/main/java/com/example/slowclock/ui/main/MainScreen.kt b/feature/main/src/main/java/com/example/slowclock/ui/main/MainScreen.kt index 88c1eab3..e61a7665 100644 --- a/feature/main/src/main/java/com/example/slowclock/ui/main/MainScreen.kt +++ b/feature/main/src/main/java/com/example/slowclock/ui/main/MainScreen.kt @@ -4,8 +4,11 @@ import android.content.Intent import android.provider.Settings import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn @@ -14,6 +17,7 @@ import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.outlined.Person import androidx.compose.material.icons.outlined.Settings import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.Icon @@ -22,6 +26,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar @@ -32,12 +37,14 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.core.net.toUri import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.LifecycleEventEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.example.slowclock.feature.main.R import com.example.slowclock.ui.common.ScheduleLoadingIndicator import com.example.slowclock.ui.common.components.ErrorCard import com.example.slowclock.ui.common.components.SignInPrompt @@ -69,6 +76,7 @@ fun MainScreen( val context = LocalContext.current val snackbarHostState = remember { SnackbarHostState() } val scope = rememberCoroutineScope() + val notificationSettingsUnavailable = stringResource(R.string.notification_settings_unavailable) ObserveSignal( signal = state.userMessage, consumed = MainIntent.ConsumeUserMessage, @@ -92,6 +100,27 @@ fun MainScreen( ) if (!opened) viewModel.onIntent(MainIntent.ExactAlarmSettingsUnavailable) } + ObserveSignal( + signal = state.openNotificationSettings, + consumed = MainIntent.ConsumeNotificationSettingsRequest, + onIntent = viewModel::onIntent, + ) { + val opened = + launchExternalActivity( + open = { + context.startActivity( + Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS) + .putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName), + ) + }, + fallback = { + context.startActivity(Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, "package:${context.packageName}".toUri())) + }, + ) + if (!opened) { + scope.launch { snackbarHostState.showSnackbar(notificationSettingsUnavailable) } + } + } MainContent( state = state, onIntent = viewModel::onIntent, @@ -240,6 +269,32 @@ internal fun MainContent( return@LazyColumn } + if (!state.alarmControlsAvailable) { + item { + Surface( + color = MaterialTheme.colorScheme.errorContainer, + shape = MaterialTheme.shapes.medium, + ) { + Column( + modifier = Modifier.fillMaxWidth().padding(20.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = stringResource(R.string.alarm_notifications_disabled), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onErrorContainer, + ) + Button( + onClick = { onIntent(MainIntent.OpenNotificationSettings) }, + modifier = Modifier.fillMaxWidth().heightIn(min = 56.dp), + ) { + Text(stringResource(R.string.open_notification_settings)) + } + } + } + } + } + state.currentSchedule?.let { schedule -> item { NowCard( 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 98f247d1..16a4245e 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 @@ -54,6 +54,7 @@ class MainViewModel init { observeSignedInUser() + dispatch(MainReducerEvent.AlarmControlsChecked(alarmScheduler.canShowAlarmControls())) // 정확한 알람 권한이 없으면 첫 진입에 한 번만 이유를 설명한다. 설정으로 보내는 건 // 사용자가 「설정 열기」 를 눌렀을 때다(#83). if (!alarmScheduler.canScheduleExactAlarms() && !settingsRepository.hasSeenExactAlarmNotice()) { @@ -66,6 +67,7 @@ class MainViewModel override fun onIntent(intent: MainIntent) { when (intent) { MainIntent.ScreenResumed -> { + dispatch(MainReducerEvent.AlarmControlsChecked(alarmScheduler.canShowAlarmControls())) resubscribeIfDayChanged() authRepository.currentUid?.let(::syncAlarms) } @@ -131,6 +133,14 @@ class MainViewModel dispatch(MainReducerEvent.ExactAlarmNoticeDismissed) } + MainIntent.OpenNotificationSettings -> { + dispatch(MainReducerEvent.NotificationSettingsRequested) + } + + MainIntent.ConsumeNotificationSettingsRequest -> { + dispatch(MainReducerEvent.NotificationSettingsRequestConsumed) + } + MainIntent.ConsumeExactAlarmSettingsRequest -> { dispatch(MainReducerEvent.ExactAlarmSettingsRequestConsumed) } @@ -276,6 +286,18 @@ class MainViewModel state.copy(error = null, canRetry = false, failedDelete = null) } + is MainReducerEvent.AlarmControlsChecked -> { + state.copy(alarmControlsAvailable = event.available) + } + + MainReducerEvent.NotificationSettingsRequested -> { + state.copy(openNotificationSettings = Unit) + } + + MainReducerEvent.NotificationSettingsRequestConsumed -> { + state.copy(openNotificationSettings = null) + } + MainReducerEvent.ExactAlarmNoticeShown -> { state.copy(showExactAlarmNotice = true) } diff --git a/feature/main/src/main/res/values/strings.xml b/feature/main/src/main/res/values/strings.xml new file mode 100644 index 00000000..e027219e --- /dev/null +++ b/feature/main/src/main/res/values/strings.xml @@ -0,0 +1,6 @@ + + + 알림이 꺼져 있어 알람이 울리지 않습니다. 설정에서 앱 알림과 알람 채널을 허용해 주세요. + 알림 설정 열기 + 설정을 열지 못했습니다. 휴대전화 설정에서 느린 시계 알림을 허용해 주세요. + diff --git a/feature/main/src/screenshotTest/java/com/example/slowclock/ui/MainScreenshotTest.kt b/feature/main/src/screenshotTest/java/com/example/slowclock/ui/MainScreenshotTest.kt index 33edaaed..f7bcd83e 100644 --- a/feature/main/src/screenshotTest/java/com/example/slowclock/ui/MainScreenshotTest.kt +++ b/feature/main/src/screenshotTest/java/com/example/slowclock/ui/MainScreenshotTest.kt @@ -144,3 +144,21 @@ internal fun SettingsContentWithNoticeScreenshot() { ) } } + +@PreviewTest +@Preview(name = "알람 알림 차단 안내", showBackground = true, device = "spec:width=360dp,height=1100dp", fontScale = 1.5f) +@Composable +internal fun MainNotificationsDisabledScreenshot() { + SlowClockTheme(darkTheme = false) { + MainContent( + state = loadedState.copy(alarmControlsAvailable = false), + onIntent = {}, + onAddSchedule = {}, + onEditSchedule = {}, + onNavigateToProfile = {}, + onNavigateToSettings = {}, + onSignIn = {}, + today = fixedToday, + ) + } +} diff --git "a/feature/main/src/screenshotTestDebug/reference/com/example/slowclock/ui/MainScreenshotTestKt/MainNotificationsDisabledScreenshot_\354\225\214\353\236\214 \354\225\214\353\246\274 \354\260\250\353\213\250 \354\225\210\353\202\264_f300fa05_0.png" "b/feature/main/src/screenshotTestDebug/reference/com/example/slowclock/ui/MainScreenshotTestKt/MainNotificationsDisabledScreenshot_\354\225\214\353\236\214 \354\225\214\353\246\274 \354\260\250\353\213\250 \354\225\210\353\202\264_f300fa05_0.png" new file mode 100644 index 00000000..8b1ccbb1 Binary files /dev/null and "b/feature/main/src/screenshotTestDebug/reference/com/example/slowclock/ui/MainScreenshotTestKt/MainNotificationsDisabledScreenshot_\354\225\214\353\236\214 \354\225\214\353\246\274 \354\260\250\353\213\250 \354\225\210\353\202\264_f300fa05_0.png" differ 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 ae9b8084..dd0c2148 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 @@ -64,6 +64,7 @@ class MainViewModelTest { coEvery { userRepository.registerShareCodeWatcher(any()) } returns true // 기본값은 정시 알람이 허용된 기기다. 안내 다이얼로그를 다루는 테스트만 이 값을 뒤집는다. every { alarmScheduler.canScheduleExactAlarms() } returns true + every { alarmScheduler.canShowAlarmControls() } returns true every { settingsRepository.hasSeenExactAlarmNotice() } returns false every { settingsRepository.markExactAlarmNoticeSeen() } returns Unit } @@ -73,6 +74,21 @@ class MainViewModelTest { Dispatchers.resetMain() } + @Test + fun `알림 차단 안내는 설정에서 허용한 뒤 화면 복귀하면 사라진다`() = + runTest { + every { alarmScheduler.canShowAlarmControls() } returns false + val viewModel = createViewModel() + assertFalse(viewModel.uiState.value.alarmControlsAvailable) + viewModel.onIntent(MainIntent.OpenNotificationSettings) + assertNotNull(viewModel.uiState.value.openNotificationSettings) + viewModel.onIntent(MainIntent.ConsumeNotificationSettingsRequest) + assertNull(viewModel.uiState.value.openNotificationSettings) + every { alarmScheduler.canShowAlarmControls() } returns true + viewModel.onIntent(MainIntent.ScreenResumed) + assertTrue(viewModel.uiState.value.alarmControlsAvailable) + } + private fun createViewModel() = MainViewModel(scheduleRepository, userRepository, authRepository, settingsRepository, alarmScheduler) @Test @@ -309,6 +325,7 @@ class MainViewModelTest { fun `정시 알람을 못 걸고 안내를 본 적 없으면 안내 다이얼로그를 띄운다`() = runTest { every { alarmScheduler.canScheduleExactAlarms() } returns false + every { alarmScheduler.canShowAlarmControls() } returns true val state = createViewModel().uiState.value @@ -320,6 +337,7 @@ class MainViewModelTest { fun `안내를 이미 봤으면 다시 띄우지 않는다`() = runTest { every { alarmScheduler.canScheduleExactAlarms() } returns false + every { alarmScheduler.canShowAlarmControls() } returns true every { settingsRepository.hasSeenExactAlarmNotice() } returns true assertFalse(createViewModel().uiState.value.showExactAlarmNotice) @@ -329,6 +347,7 @@ class MainViewModelTest { fun `설정 열기는 본 것으로 표시하고 설정 열기 신호를 냈다가 소비한다`() = runTest { every { alarmScheduler.canScheduleExactAlarms() } returns false + every { alarmScheduler.canShowAlarmControls() } returns true val viewModel = createViewModel() viewModel.onIntent(MainIntent.OpenExactAlarmSettings) @@ -347,6 +366,7 @@ class MainViewModelTest { fun `나중에를 누르면 안내를 닫고 본 것으로 표시한다`() = runTest { every { alarmScheduler.canScheduleExactAlarms() } returns false + every { alarmScheduler.canShowAlarmControls() } returns true val viewModel = createViewModel() viewModel.onIntent(MainIntent.DismissExactAlarmNotice)