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 <noreply@anthropic.com>
This commit is contained in:
2026-09-11 16:04:40 +02:00
co-authored by Claude Opus 5
parent e471c9b750
commit 0beb1749fc
4 changed files with 248 additions and 0 deletions
@@ -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
}
@@ -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)
}
}
@@ -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<CapabilitySnapshot> =
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()
}
}
@@ -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()
}
}