Slice 2: guided Surface calibration flow (Codex)

- SurfaceCalibrationScreen: two-sample guided flow from the Level header.
  Captures read SensorSource.gravity directly - stored calibration is
  never applied to either sample. Exact 180-degree same-plane guidance,
  clear-saved-calibration action, Surface persistence only.
- SettlingDetector (core/sensors, shared): 1.0 deg/s enter, 0.3 deg/s
  exit held 500 ms; mid-band movement resets the quiet dwell without
  leaving Settling (audit finding 1).
- StableSurfaceCapture (core/sensors): continuous 1.5 s settled window
  with the 5-degree calibration-surface guard; restarts on motion.
- SurfaceCalibrationValidation (core/sensors): 3-degree bound on the
  combined two-axis bias magnitude, unit-tested both sides of the
  boundary (audit finding 2).
- Lock feedback suppression is structural: the calibration route removes
  LevelScreen from composition, cancelling its sensor collection.

50 tests passing.

Audited-by: Claude (2 findings raised and resolved)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jay
2026-07-13 14:51:28 -04:00
parent 8d942a88b4
commit d3854ac402
10 changed files with 454 additions and 3 deletions
@@ -26,6 +26,7 @@ import androidx.navigation.compose.rememberNavController
import com.onthelevel.core.design.OnTheLevelTheme
import com.onthelevel.feature.angle.AngleScreen
import com.onthelevel.feature.level.LevelScreen
import com.onthelevel.feature.level.SurfaceCalibrationScreen
import com.onthelevel.feature.ruler.RulerScreen
import com.onthelevel.feature.tools.ToolsScreen
@@ -87,12 +88,19 @@ private fun AppRoot(container: AppContainer) {
startDestination = "level",
modifier = Modifier.padding(padding),
) {
composable("level") { LevelScreen(container) }
composable("level") {
LevelScreen(container, onOpenSurfaceCalibration = {
navController.navigate("surface-calibration")
})
}
composable("angle") { AngleScreen(container) }
composable("tools") {
ToolsScreen(onOpenRuler = { navController.navigate("ruler") })
}
composable("ruler") { RulerScreen(container) }
composable("surface-calibration") {
SurfaceCalibrationScreen(container, onBack = { navController.popBackStack() })
}
}
}
}
@@ -0,0 +1,70 @@
package com.onthelevel.core.sensors
import kotlin.math.hypot
/** Shared two-axis motion gate for calibration, guidance, and Audio Assist. */
class SettlingDetector(
private val enterRateDegreesPerSecond: Double = ENTER_RATE_DEGREES_PER_SECOND,
private val exitRateDegreesPerSecond: Double = EXIT_RATE_DEGREES_PER_SECOND,
private val settledDwellMillis: Long = SETTLED_DWELL_MILLIS,
) {
data class Result(val isSettling: Boolean, val movementRateDegreesPerSecond: Double)
private var lastPitchDegrees: Double? = null
private var lastRollDegrees: Double? = null
private var lastTimestampNanos: Long? = null
private var settledSinceMillis: Long? = null
private var isSettling = true
fun update(pitchDegrees: Double, rollDegrees: Double, timestampNanos: Long): Result {
val previousPitch = lastPitchDegrees
val previousRoll = lastRollDegrees
val previousTimestamp = lastTimestampNanos
lastPitchDegrees = pitchDegrees
lastRollDegrees = rollDegrees
lastTimestampNanos = timestampNanos
if (previousPitch == null || previousRoll == null || previousTimestamp == null) {
return Result(isSettling = true, movementRateDegreesPerSecond = 0.0)
}
val dtSeconds = (timestampNanos - previousTimestamp) / 1e9
if (dtSeconds <= 0.0) {
reset()
return Result(isSettling = true, movementRateDegreesPerSecond = 0.0)
}
val rate = hypot(pitchDegrees - previousPitch, rollDegrees - previousRoll) / dtSeconds
val nowMillis = timestampNanos / 1_000_000
when {
rate > enterRateDegreesPerSecond -> {
isSettling = true
settledSinceMillis = null
}
isSettling && rate > exitRateDegreesPerSecond -> {
// A mid-band hand nudge is not enough to leave Settling, but it is
// enough to prove the signal has not been continuously quiet for
// the required dwell window.
settledSinceMillis = null
}
isSettling && rate <= exitRateDegreesPerSecond -> {
val since = settledSinceMillis ?: nowMillis.also { settledSinceMillis = it }
if (nowMillis - since >= settledDwellMillis) isSettling = false
}
}
return Result(isSettling = isSettling, movementRateDegreesPerSecond = rate)
}
fun reset() {
lastPitchDegrees = null
lastRollDegrees = null
lastTimestampNanos = null
settledSinceMillis = null
isSettling = true
}
companion object {
const val ENTER_RATE_DEGREES_PER_SECOND = 1.0
const val EXIT_RATE_DEGREES_PER_SECOND = 0.3
const val SETTLED_DWELL_MILLIS = 500L
}
}
@@ -0,0 +1,49 @@
package com.onthelevel.core.sensors
/** Uncalibrated, smoothed Surface sample used by guided calibration. */
data class SurfaceCalibrationSample(
val pitchDegrees: Double,
val rollDegrees: Double,
val tiltDegrees: Double,
val timestampNanos: Long,
val isSettling: Boolean,
)
/** Mean of one settled 1.5-second calibration capture. */
data class CapturedSurfaceSample(val pitchDegrees: Double, val rollDegrees: Double)
/** Collects a continuous settled capture on a roughly-level surface. */
class StableSurfaceCapture(
private val durationMillis: Long = CAPTURE_DURATION_MILLIS,
private val maximumTiltDegrees: Double = MAXIMUM_CALIBRATION_TILT_DEGREES,
) {
private var startedAtNanos: Long? = null
private var pitchTotal = 0.0
private var rollTotal = 0.0
private var count = 0
fun add(sample: SurfaceCalibrationSample): CapturedSurfaceSample? {
if (sample.isSettling || sample.tiltDegrees > maximumTiltDegrees) {
reset()
return null
}
val startedAt = startedAtNanos ?: sample.timestampNanos.also { startedAtNanos = it }
pitchTotal += sample.pitchDegrees
rollTotal += sample.rollDegrees
count += 1
if ((sample.timestampNanos - startedAt) / 1_000_000 < durationMillis || count < 2) return null
return CapturedSurfaceSample(pitchTotal / count, rollTotal / count)
}
fun reset() {
startedAtNanos = null
pitchTotal = 0.0
rollTotal = 0.0
count = 0
}
companion object {
const val CAPTURE_DURATION_MILLIS = 1_500L
const val MAXIMUM_CALIBRATION_TILT_DEGREES = 5.0
}
}
@@ -0,0 +1,14 @@
package com.onthelevel.core.sensors
import kotlin.math.hypot
/**
* Guards calibration persistence against an inconsistent two-sample turn. The bound
* applies to the combined two-axis correction, not each axis independently.
*/
object SurfaceCalibrationValidation {
const val MAXIMUM_BIAS_MAGNITUDE_DEGREES = 3.0
fun isAcceptable(calibration: SurfaceCalibration): Boolean =
hypot(calibration.pitchBiasDegrees, calibration.rollBiasDegrees) <= MAXIMUM_BIAS_MAGNITUDE_DEGREES
}
@@ -49,6 +49,8 @@ class SettingsRepository(context: Context) {
}
}
suspend fun clearSurfaceCalibration() = setSurfaceCalibration(SurfaceCalibration.NONE)
suspend fun setEdgeCalibration(calibration: EdgeCalibration) {
store.edit {
it[Keys.EDGE_LEVEL_BIAS] = calibration.levelBiasDegrees
@@ -54,7 +54,7 @@ import java.util.Locale
* while the portrait-locked device stands on its long edge.
*/
@Composable
fun LevelScreen(container: AppContainer) {
fun LevelScreen(container: AppContainer, onOpenSurfaceCalibration: () -> Unit) {
KeepScreenOn()
val sensorSource = container.sensorSource
@@ -107,7 +107,7 @@ fun LevelScreen(container: AppContainer) {
color = if (isCalibrated) LevelColors.LimeLock else LevelColors.TextFaint,
)
}
IconButton(onClick = { /* TODO(feature): calibration & settings entry */ }) {
IconButton(onClick = onOpenSurfaceCalibration) {
Icon(
Icons.Outlined.Settings,
contentDescription = "Calibration and settings",
@@ -0,0 +1,203 @@
package com.onthelevel.feature.level
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.onthelevel.AppContainer
import com.onthelevel.core.design.KeepScreenOn
import com.onthelevel.core.design.LevelColors
import com.onthelevel.core.sensors.CapturedSurfaceSample
import com.onthelevel.core.sensors.Ema
import com.onthelevel.core.sensors.OrientationMath
import com.onthelevel.core.sensors.SettlingDetector
import com.onthelevel.core.sensors.StableSurfaceCapture
import com.onthelevel.core.sensors.SurfaceCalibration
import com.onthelevel.core.sensors.SurfaceCalibrationSample
import com.onthelevel.core.sensors.SurfaceCalibrationValidation
import com.onthelevel.core.sensors.TwoSampleCalibration
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
private enum class CalibrationPhase { FIRST_READY, FIRST_CAPTURING, SECOND_READY, SECOND_CAPTURING, COMPLETE, ERROR }
/**
* Guided two-sample Surface calibration. Capture reads SensorSource.gravity directly
* and never applies stored SurfaceCalibration to either sample.
*/
@Composable
fun SurfaceCalibrationScreen(container: AppContainer, onBack: () -> Unit) {
KeepScreenOn()
val sensorSource = container.sensorSource
val scope = rememberCoroutineScope()
var phase by remember { mutableStateOf(CalibrationPhase.FIRST_READY) }
var firstCapture by remember { mutableStateOf<CapturedSurfaceSample?>(null) }
var capture by remember { mutableStateOf<StableSurfaceCapture?>(null) }
var error by remember { mutableStateOf<String?>(null) }
val existingCalibration by container.settings.surfaceCalibration
.collectAsStateWithLifecycle(initialValue = SurfaceCalibration.NONE)
val rawReadingFlow = remember(sensorSource) {
val pitchEma = Ema(0.15)
val rollEma = Ema(0.15)
val tiltEma = Ema(0.15)
val settling = SettlingDetector()
var lastNanos: Long? = null
sensorSource.gravity.map { raw ->
val dtSeconds = lastNanos?.let { (raw.timestampNanos - it) / 1e9 } ?: 0.0
lastNanos = raw.timestampNanos
val pitch = pitchEma.update(OrientationMath.surfacePitchDegrees(raw), dtSeconds)
val roll = rollEma.update(OrientationMath.surfaceRollDegrees(raw), dtSeconds)
val tilt = tiltEma.update(OrientationMath.surfaceTiltMagnitudeDegrees(raw), dtSeconds)
val settlingResult = settling.update(pitch, roll, raw.timestampNanos)
SurfaceCalibrationSample(pitch, roll, tilt, raw.timestampNanos, settlingResult.isSettling)
}
}
val reading by rawReadingFlow.collectAsStateWithLifecycle(initialValue = null)
LaunchedEffect(reading, phase, capture, firstCapture) {
val activeCapture = capture ?: return@LaunchedEffect
val current = reading ?: return@LaunchedEffect
val captured = activeCapture.add(current) ?: return@LaunchedEffect
when (phase) {
CalibrationPhase.FIRST_CAPTURING -> {
firstCapture = captured
capture = null
phase = CalibrationPhase.SECOND_READY
}
CalibrationPhase.SECOND_CAPTURING -> {
val first = firstCapture ?: return@LaunchedEffect
val calibration = TwoSampleCalibration.deriveSurface(
first.pitchDegrees,
first.rollDegrees,
captured.pitchDegrees,
captured.rollDegrees,
)
capture = null
if (!SurfaceCalibrationValidation.isAcceptable(calibration)) {
error = "The placements did not agree. Keep the phone on the same spot and rotate it exactly halfway around."
phase = CalibrationPhase.ERROR
} else {
container.settings.setSurfaceCalibration(calibration)
phase = CalibrationPhase.COMPLETE
}
}
else -> Unit
}
}
val canStartCapture = reading?.let {
!it.isSettling && it.tiltDegrees <= StableSurfaceCapture.MAXIMUM_CALIBRATION_TILT_DEGREES
} ?: false
val status = when {
reading == null -> "Waiting for a sensor reading"
reading!!.isSettling -> "Settling—keep the phone still"
reading!!.tiltDegrees > StableSurfaceCapture.MAXIMUM_CALIBRATION_TILT_DEGREES ->
"Find a flatter surface (under 5°)"
else -> "Ready to capture"
}
Column(
modifier = Modifier.fillMaxSize().padding(horizontal = 24.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(18.dp),
) {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text("SURFACE CALIBRATION", style = MaterialTheme.typography.labelSmall, color = LevelColors.TextDim)
OutlinedButton(onClick = onBack) { Text("Done") }
}
Text(calibrationInstruction(phase), style = MaterialTheme.typography.headlineMedium, color = LevelColors.TextPrimary)
Text(
"This corrects your phone and case setup. It does not make this surface level.",
style = MaterialTheme.typography.bodyMedium,
color = LevelColors.TextDim,
)
Box(modifier = Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
reading?.let { formatDegrees(it.tiltDegrees) } ?: "",
style = MaterialTheme.typography.displayLarge,
color = LevelColors.TextPrimary,
)
Text(status, style = MaterialTheme.typography.bodyLarge, color = LevelColors.Amber)
}
}
when (phase) {
CalibrationPhase.FIRST_READY, CalibrationPhase.SECOND_READY -> Button(
onClick = {
capture = StableSurfaceCapture()
phase = if (phase == CalibrationPhase.FIRST_READY) CalibrationPhase.FIRST_CAPTURING else CalibrationPhase.SECOND_CAPTURING
},
enabled = canStartCapture,
modifier = Modifier.fillMaxWidth(),
) {
Text(if (phase == CalibrationPhase.FIRST_READY) "Capture first position" else "Capture rotated position")
}
CalibrationPhase.FIRST_CAPTURING, CalibrationPhase.SECOND_CAPTURING -> Text(
"Capturing 1.5 seconds of still readings…",
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodyLarge,
color = LevelColors.TextDim,
)
CalibrationPhase.COMPLETE -> {
Text(
"Surface calibration saved for this phone/case setup.",
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodyLarge,
color = LevelColors.LimeLockText,
)
Button(onClick = onBack, modifier = Modifier.fillMaxWidth()) { Text("Back to level") }
}
CalibrationPhase.ERROR -> {
Text(error ?: "Calibration could not be completed.", style = MaterialTheme.typography.bodyLarge, color = LevelColors.Amber)
Button(
onClick = {
firstCapture = null
error = null
phase = CalibrationPhase.FIRST_READY
},
modifier = Modifier.fillMaxWidth(),
) { Text("Try again") }
}
}
if (existingCalibration != SurfaceCalibration.NONE && phase != CalibrationPhase.FIRST_CAPTURING && phase != CalibrationPhase.SECOND_CAPTURING) {
OutlinedButton(
onClick = { scope.launch { container.settings.clearSurfaceCalibration() } },
modifier = Modifier.fillMaxWidth(),
) { Text("Clear saved calibration") }
}
}
}
private fun calibrationInstruction(phase: CalibrationPhase): String = when (phase) {
CalibrationPhase.FIRST_READY, CalibrationPhase.FIRST_CAPTURING ->
"Lay the phone screen-up on a firm, roughly level surface. Keep it still."
CalibrationPhase.SECOND_READY, CalibrationPhase.SECOND_CAPTURING ->
"Rotate the phone exactly 180° in the same plane. Do not flip it over or move it."
CalibrationPhase.COMPLETE -> "Calibration complete"
CalibrationPhase.ERROR -> "Try Surface calibration again"
}
@@ -0,0 +1,43 @@
package com.onthelevel.core.sensors
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class SettlingDetectorTest {
@Test
fun `settles only after low movement has held for its dwell time`() {
val detector = SettlingDetector()
assertTrue(detector.update(0.0, 0.0, 0).isSettling)
assertTrue(detector.update(0.2, 0.0, 100_000_000).isSettling) // 2°/s: moving
assertTrue(detector.update(0.2, 0.0, 200_000_000).isSettling)
assertTrue(detector.update(0.2, 0.0, 600_000_000).isSettling)
assertFalse(detector.update(0.2, 0.0, 700_000_000).isSettling)
}
@Test
fun `movement re-enters settling and resets the dwell`() {
val detector = SettlingDetector()
detector.update(0.0, 0.0, 0)
detector.update(0.0, 0.0, 100_000_000)
detector.update(0.0, 0.0, 600_000_000)
assertFalse(detector.update(0.0, 0.0, 700_000_000).isSettling)
assertTrue(detector.update(0.2, 0.0, 800_000_000).isSettling)
assertTrue(detector.update(0.2, 0.0, 1_200_000_000).isSettling)
}
@Test
fun `mid band movement resets the quiet dwell without leaving settling`() {
val detector = SettlingDetector()
detector.update(0.0, 0.0, 0)
detector.update(0.0, 0.0, 100_000_000) // quiet dwell starts
detector.update(0.08, 0.0, 300_000_000) // 0.4°/s: between exit and enter
assertTrue(detector.update(0.08, 0.0, 600_000_000).isSettling)
assertTrue(detector.update(0.08, 0.0, 1_000_000_000).isSettling)
assertFalse(detector.update(0.08, 0.0, 1_100_000_000).isSettling)
}
}
@@ -0,0 +1,42 @@
package com.onthelevel.core.sensors
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class StableSurfaceCaptureTest {
private fun sample(
pitch: Double,
roll: Double,
timestampMillis: Long,
tilt: Double = 1.0,
settling: Boolean = false,
) = SurfaceCalibrationSample(pitch, roll, tilt, timestampMillis * 1_000_000, settling)
@Test
fun `captures the mean of a continuous settled one point five second window`() {
val capture = StableSurfaceCapture()
assertNull(capture.add(sample(0.2, -0.1, 0)))
assertNull(capture.add(sample(0.4, -0.3, 750)))
val result = capture.add(sample(0.6, -0.5, 1_500))
requireNotNull(result)
assertEquals(0.4, result.pitchDegrees, 1e-9)
assertEquals(-0.3, result.rollDegrees, 1e-9)
}
@Test
fun `motion or excessive tilt resets the capture window`() {
val capture = StableSurfaceCapture()
assertNull(capture.add(sample(0.2, 0.0, 0)))
assertNull(capture.add(sample(0.2, 0.0, 750, settling = true)))
assertNull(capture.add(sample(0.2, 0.0, 1_000)))
assertNull(capture.add(sample(0.2, 0.0, 1_500, tilt = 5.1)))
assertNull(capture.add(sample(0.2, 0.0, 1_750)))
assertNull(capture.add(sample(0.2, 0.0, 2_500)))
requireNotNull(capture.add(sample(0.2, 0.0, 3_250)))
}
}
@@ -0,0 +1,20 @@
package com.onthelevel.core.sensors
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class SurfaceCalibrationValidationTest {
@Test
fun `accepts a combined bias at or below three degrees`() {
assertTrue(SurfaceCalibrationValidation.isAcceptable(SurfaceCalibration(3.0, 0.0)))
assertTrue(SurfaceCalibrationValidation.isAcceptable(SurfaceCalibration(1.8, 2.4)))
}
@Test
fun `rejects a combined bias above three degrees`() {
assertFalse(SurfaceCalibrationValidation.isAcceptable(SurfaceCalibration(3.01, 0.0)))
assertFalse(SurfaceCalibrationValidation.isAcceptable(SurfaceCalibration(2.5, 2.5)))
}
}