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:
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user