From 0beb1749fcae4b83883af07b32ca440f2636a7af Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Fri, 11 Sep 2026 16:04:40 +0200 Subject: [PATCH] feat(domain): the ring policies and the volume ramp Both are pure functions so they can be tested without a device. The ramp in particular: a fade-in is the kind of thing that is easy to get subtly wrong and impossible to notice until an alarm opens at full volume or never reaches it. Co-Authored-By: Claude Opus 5 --- .../clockula/domain/alarm/RingPolicies.kt | 52 ++++++++ .../clockula/domain/alarm/VolumeRamp.kt | 19 +++ .../clockula/domain/alarm/RingPoliciesTest.kt | 112 ++++++++++++++++++ .../clockula/domain/alarm/VolumeRampTest.kt | 65 ++++++++++ 4 files changed, 248 insertions(+) create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/domain/alarm/RingPolicies.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/domain/alarm/VolumeRamp.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/clockula/domain/alarm/RingPoliciesTest.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/clockula/domain/alarm/VolumeRampTest.kt diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/alarm/RingPolicies.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/alarm/RingPolicies.kt new file mode 100644 index 0000000..303c914 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/alarm/RingPolicies.kt @@ -0,0 +1,52 @@ +package de.jeanlucmakiola.clockula.domain.alarm + +/** What the platform will currently let the app do. */ +data class CapabilitySnapshot( + val canScheduleExactAlarms: Boolean, + val canUseFullScreenIntent: Boolean, + val notificationsEnabled: Boolean, +) + +data class RingPresentation( + val useFullScreenIntent: Boolean, + val headsUp: Boolean, + /** The audio belongs to the foreground service, not the notification. */ + val soundsAnyway: Boolean, +) + +object RingPresentationPolicy { + /** + * A denied full-screen intent degrades to a heads-up notification, never to + * nothing — and either way the alarm sounds, because the sound is the + * foreground service's and not the notification's. + */ + fun forCapabilities(caps: CapabilitySnapshot): RingPresentation = RingPresentation( + useFullScreenIntent = caps.canUseFullScreenIntent, + headsUp = !caps.canUseFullScreenIntent, + soundsAnyway = true, + ) +} + +object RingFallbackPolicy { + /** True when the alarm must vibrate — including when it wants to but cannot make a sound. */ + fun vibrationRequired(audioAvailable: Boolean, vibrateSetting: Boolean): Boolean = + vibrateSetting || !audioAvailable +} + +enum class ScheduleMode { EXACT_ALARM_CLOCK, INEXACT_FALLBACK } + +object SchedulingPolicy { + /** Two branches only: late is survivable, silent is not. */ + fun modeFor(canScheduleExactAlarms: Boolean): ScheduleMode = + if (canScheduleExactAlarms) ScheduleMode.EXACT_ALARM_CLOCK else ScheduleMode.INEXACT_FALLBACK +} + +/** Long buzz, short gap, long buzz, longer gap — loudly rhythmic, not a doorbell. */ +object AlarmVibrationPattern { + val TIMINGS: LongArray = longArrayOf(500L, 250L, 500L, 1_000L) + + val AMPLITUDES: IntArray = intArrayOf(255, 0, 255, 0) + + /** The index the pattern loops from: 0, so the whole pattern repeats. */ + const val REPEAT_INDEX: Int = 0 +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/alarm/VolumeRamp.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/alarm/VolumeRamp.kt new file mode 100644 index 0000000..a6b2f8d --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/alarm/VolumeRamp.kt @@ -0,0 +1,19 @@ +package de.jeanlucmakiola.clockula.domain.alarm + +import kotlin.time.Duration + +/** + * Quadratic in amplitude is roughly linear in perceived loudness, so the ramp + * sounds like a steady climb rather than a jump at the end. + */ +object VolumeRamp { + /** `AlarmRing.MIN_VOLUME`..1f. Never throws. */ + fun levelAt(elapsed: Duration, ramp: Duration): Float { + if (ramp <= Duration.ZERO || elapsed >= ramp) return 1f + if (elapsed <= Duration.ZERO) return AlarmRing.MIN_VOLUME + + val progress = (elapsed / ramp).toFloat() + val level = AlarmRing.MIN_VOLUME + (1f - AlarmRing.MIN_VOLUME) * progress * progress + return level.coerceIn(AlarmRing.MIN_VOLUME, 1f) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/domain/alarm/RingPoliciesTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/domain/alarm/RingPoliciesTest.kt new file mode 100644 index 0000000..3011bcf --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/domain/alarm/RingPoliciesTest.kt @@ -0,0 +1,112 @@ +package de.jeanlucmakiola.clockula.domain.alarm + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test + +/** + * "Never to silence" is the alarm app's one non-negotiable, so it is asserted + * over the whole truth table rather than at a representative point. + */ +class RingPoliciesTest { + + private fun caps( + exact: Boolean = true, + fullScreen: Boolean = true, + notifications: Boolean = true, + ) = CapabilitySnapshot( + canScheduleExactAlarms = exact, + canUseFullScreenIntent = fullScreen, + notificationsEnabled = notifications, + ) + + private val allCapabilities: List = + listOf(true, false).flatMap { exact -> + listOf(true, false).flatMap { fullScreen -> + listOf(true, false).map { notifications -> caps(exact, fullScreen, notifications) } + } + } + + @Test + fun `a permitted full-screen intent is used`() { + val presentation = RingPresentationPolicy.forCapabilities(caps(fullScreen = true)) + + assertThat(presentation.useFullScreenIntent).isTrue() + } + + @Test + fun `a denied full-screen intent degrades to a heads-up notification`() { + val presentation = RingPresentationPolicy.forCapabilities(caps(fullScreen = false)) + + assertThat(presentation.useFullScreenIntent).isFalse() + assertThat(presentation.headsUp).isTrue() + } + + @Test + fun `disabled notifications do not mute the alarm`() { + val presentation = RingPresentationPolicy.forCapabilities(caps(notifications = false)) + + assertThat(presentation.soundsAnyway).isTrue() + } + + @Test + fun `the alarm sounds under every combination of capabilities`() { + val silent = allCapabilities.filterNot { RingPresentationPolicy.forCapabilities(it).soundsAnyway } + + assertThat(silent).isEmpty() + } + + @Test + fun `no audio source forces vibration on even when the alarm has it switched off`() { + val required = RingFallbackPolicy.vibrationRequired(audioAvailable = false, vibrateSetting = false) + + assertThat(required).isTrue() + } + + @Test + fun `an audible alarm with vibration switched off does not vibrate`() { + val required = RingFallbackPolicy.vibrationRequired(audioAvailable = true, vibrateSetting = false) + + assertThat(required).isFalse() + } + + @Test + fun `an audible alarm with vibration switched on vibrates`() { + val required = RingFallbackPolicy.vibrationRequired(audioAvailable = true, vibrateSetting = true) + + assertThat(required).isTrue() + } + + @Test + fun `a silent alarm with vibration switched on vibrates`() { + val required = RingFallbackPolicy.vibrationRequired(audioAvailable = false, vibrateSetting = true) + + assertThat(required).isTrue() + } + + @Test + fun `exact alarms give the alarm-clock slot`() { + assertThat(SchedulingPolicy.modeFor(canScheduleExactAlarms = true)) + .isEqualTo(ScheduleMode.EXACT_ALARM_CLOCK) + } + + @Test + fun `without exact alarms the alarm still gets scheduled, inexactly`() { + assertThat(SchedulingPolicy.modeFor(canScheduleExactAlarms = false)) + .isEqualTo(ScheduleMode.INEXACT_FALLBACK) + // There is no third branch to fall into — "do not schedule" is not a mode. + assertThat(ScheduleMode.entries).hasSize(2) + } + + @Test + fun `the vibration pattern is a loopable, non-empty pair of timings and amplitudes`() { + val timings = AlarmVibrationPattern.TIMINGS + val amplitudes = AlarmVibrationPattern.AMPLITUDES + + assertThat(timings.size).isEqualTo(amplitudes.size) + assertThat(timings.size).isGreaterThan(0) + assertThat(AlarmVibrationPattern.REPEAT_INDEX).isIn(timings.indices.toList()) + assertThat(timings.filter { it <= 0L }).isEmpty() + assertThat(amplitudes.filterNot { it in 0..255 }).isEmpty() + assertThat(amplitudes.filter { it > 0 }).isNotEmpty() + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/domain/alarm/VolumeRampTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/domain/alarm/VolumeRampTest.kt new file mode 100644 index 0000000..0057c29 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/domain/alarm/VolumeRampTest.kt @@ -0,0 +1,65 @@ +package de.jeanlucmakiola.clockula.domain.alarm + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +/** + * The ramp is a pure function of elapsed time, so "it never starts at silence + * and never overshoots" is an assertion rather than a listening test. + */ +class VolumeRampTest { + + private val ramp = 15.seconds + + @Test + fun `the ramp starts at the floor rather than at silence`() { + val level = VolumeRamp.levelAt(Duration.ZERO, ramp) + + assertThat(level).isEqualTo(AlarmRing.MIN_VOLUME) + } + + @Test + fun `the ramp reaches full volume exactly at its end`() { + val level = VolumeRamp.levelAt(ramp, ramp) + + assertThat(level).isEqualTo(1f) + } + + @Test + fun `past the end the ramp stays at full volume`() { + val level = VolumeRamp.levelAt(30.seconds, ramp) + + assertThat(level).isEqualTo(1f) + } + + @Test + fun `a zero ramp is full volume immediately`() { + assertThat(VolumeRamp.levelAt(5.seconds, Duration.ZERO)).isEqualTo(1f) + assertThat(VolumeRamp.levelAt(Duration.ZERO, Duration.ZERO)).isEqualTo(1f) + } + + @Test + fun `a negative elapsed reading never falls below the floor`() { + val level = VolumeRamp.levelAt((-5).seconds, ramp) + + assertThat(level).isEqualTo(AlarmRing.MIN_VOLUME) + } + + @Test + fun `the ramp never gets quieter as it runs`() { + val samples = (0..100).map { step -> VolumeRamp.levelAt((ramp / 100) * step, ramp) } + + assertThat(samples.zipWithNext().filter { (a, b) -> b < a }).isEmpty() + } + + @Test + fun `every sample across and beyond the ramp stays inside the floor and the ceiling`() { + val samples = (0..100).map { step -> + VolumeRamp.levelAt((-5).seconds + (35.seconds / 100) * step, ramp) + } + + assertThat(samples.filterNot { it >= AlarmRing.MIN_VOLUME && it <= 1f }).isEmpty() + } +}