Address Codex audit: Tools cards, 4-point validation, durable persistence

P1 - Tools screen no longer exposes dead 'coming with the feature build'
cards. Replaced the stale Calibration and Units/Feedback cards with a
single working Settings card that opens the Settings screen.

P1 - Detailed (4-point) calibration now validates rotation quality, not
just the final mean. A 4-point set holds two independent 180-flip bias
estimates - the (0,180) and (90,270) pairs; a malformed turn makes them
diverge even when the mean stays small (which the 3-degree magnitude
guard can't see). isRotationConsistent rejects divergence beyond 0.4
degrees. The disagreement scales with the surface's true tilt, so it's
lenient near level and strict on a steeper permitted surface. Test
proves a deliberately 10-degree-over-rotated set is rejected and a clean
set passes.

P2 - Calibration persistence moved from the screen's rememberCoroutineScope
to a container-owned applicationScope, so the write survives an immediate
Done/back after 'saved'. Preference writes moved there too.

Cleanup: haptic copy is mode-neutral ('when the level locks'); Settings
no longer keeps the screen awake (calibration still does); calibration
method renamed Thoroughness -> Method with Standard/Detailed labels and
accurate 'Four positions, a quarter-turn apart' / 'Averages more sensor
noise' copy; lime now marks the active-calibration status (confirmed
state), consistent with lime = level/confirmed.

64 tests passing; assembleDebug clean; verified on device.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jay
2026-07-14 11:15:29 -04:00
parent 85a8ce1136
commit daf19c34b3
7 changed files with 100 additions and 33 deletions
@@ -96,7 +96,10 @@ private fun AppRoot(container: AppContainer) {
} }
composable("angle") { AngleScreen(container) } composable("angle") { AngleScreen(container) }
composable("tools") { composable("tools") {
ToolsScreen(onOpenRuler = { navController.navigate("ruler") }) ToolsScreen(
onOpenRuler = { navController.navigate("ruler") },
onOpenSettings = { navController.navigate("settings") },
)
} }
composable("ruler") { RulerScreen(container) } composable("ruler") { RulerScreen(container) }
composable("settings") { composable("settings") {
@@ -7,6 +7,9 @@ import com.onthelevel.core.billing.StubProEntitlementRepository
import com.onthelevel.core.sensors.AndroidSensorSource import com.onthelevel.core.sensors.AndroidSensorSource
import com.onthelevel.core.sensors.SensorSource import com.onthelevel.core.sensors.SensorSource
import com.onthelevel.core.settings.SettingsRepository import com.onthelevel.core.settings.SettingsRepository
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
/** /**
* Manual application container — deliberately no DI framework for an app this size * Manual application container — deliberately no DI framework for an app this size
@@ -15,6 +18,13 @@ import com.onthelevel.core.settings.SettingsRepository
class AppContainer(context: Context) { class AppContainer(context: Context) {
private val appContext = context.applicationContext private val appContext = context.applicationContext
/**
* Outlives any single screen. Used for durable fire-and-forget persistence
* (e.g. saving a calibration): a screen-scoped coroutine would be cancelled if
* the user leaves the moment the write is launched.
*/
val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
val sensorSource: SensorSource by lazy { AndroidSensorSource(appContext) } val sensorSource: SensorSource by lazy { AndroidSensorSource(appContext) }
val settings: SettingsRepository by lazy { SettingsRepository(appContext) } val settings: SettingsRepository by lazy { SettingsRepository(appContext) }
val entitlement: ProEntitlementRepository by lazy { StubProEntitlementRepository() } val entitlement: ProEntitlementRepository by lazy { StubProEntitlementRepository() }
@@ -3,12 +3,34 @@ package com.onthelevel.core.sensors
import kotlin.math.hypot import kotlin.math.hypot
/** /**
* Guards calibration persistence against an inconsistent two-sample turn. The bound * Guards calibration persistence against a bad calibration.
* applies to the combined two-axis correction, not each axis independently.
*/ */
object SurfaceCalibrationValidation { object SurfaceCalibrationValidation {
const val MAXIMUM_BIAS_MAGNITUDE_DEGREES = 3.0 const val MAXIMUM_BIAS_MAGNITUDE_DEGREES = 3.0
const val PAIR_AGREEMENT_TOLERANCE_DEGREES = 0.4
/** The combined two-axis correction must be small; a large mean means something moved. */
fun isAcceptable(calibration: SurfaceCalibration): Boolean = fun isAcceptable(calibration: SurfaceCalibration): Boolean =
hypot(calibration.pitchBiasDegrees, calibration.rollBiasDegrees) <= MAXIMUM_BIAS_MAGNITUDE_DEGREES hypot(calibration.pitchBiasDegrees, calibration.rollBiasDegrees) <= MAXIMUM_BIAS_MAGNITUDE_DEGREES
/**
* A 4-point set (0°/90°/180°/270°) contains TWO independent 180°-flip bias
* estimates: the (0,180) pair and the (90,270) pair. A clean turn makes them
* agree; a malformed rotation makes them diverge even when the overall mean stays
* plausibly small — which the magnitude guard alone cannot detect. Reject when the
* two estimates disagree beyond tolerance.
*
* The disagreement scales with the surface's true tilt, so this is naturally
* lenient near level (where a turn error barely matters) and strict on a steeper
* permitted surface (where it matters most). Sets that aren't the 4-point shape
* carry no cross-check and pass here; the magnitude guard still applies.
*/
fun isRotationConsistent(samples: List<CapturedSurfaceSample>): Boolean {
if (samples.size != 4) return true
val pitchA = (samples[0].pitchDegrees + samples[2].pitchDegrees) / 2.0
val rollA = (samples[0].rollDegrees + samples[2].rollDegrees) / 2.0
val pitchB = (samples[1].pitchDegrees + samples[3].pitchDegrees) / 2.0
val rollB = (samples[1].rollDegrees + samples[3].rollDegrees) / 2.0
return hypot(pitchA - pitchB, rollA - rollB) <= PAIR_AGREEMENT_TOLERANCE_DEGREES
}
} }
@@ -22,7 +22,6 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -62,7 +61,6 @@ fun SurfaceCalibrationScreen(container: AppContainer, onBack: () -> Unit) {
KeepScreenOn() KeepScreenOn()
val sensorSource = container.sensorSource val sensorSource = container.sensorSource
val scope = rememberCoroutineScope()
val mode by container.settings.calibrationMode val mode by container.settings.calibrationMode
.collectAsStateWithLifecycle(initialValue = CalibrationMode.SIMPLE) .collectAsStateWithLifecycle(initialValue = CalibrationMode.SIMPLE)
val totalSteps = mode.captureCount val totalSteps = mode.captureCount
@@ -112,7 +110,8 @@ fun SurfaceCalibrationScreen(container: AppContainer, onBack: () -> Unit) {
return@LaunchedEffect return@LaunchedEffect
} }
val calibration = TwoSampleCalibration.deriveSurfaceFromSamples(newCaptures) val calibration = TwoSampleCalibration.deriveSurfaceFromSamples(newCaptures)
if (!SurfaceCalibrationValidation.isAcceptable(calibration)) { val consistent = SurfaceCalibrationValidation.isRotationConsistent(newCaptures)
if (!SurfaceCalibrationValidation.isAcceptable(calibration) || !consistent) {
captures = emptyList() captures = emptyList()
error = "Those positions didn't agree. Keep the phone on the same spot, turn it in place, and don't flip it over." error = "Those positions didn't agree. Keep the phone on the same spot, turn it in place, and don't flip it over."
phase = CapturePhase.ERROR phase = CapturePhase.ERROR
@@ -121,7 +120,8 @@ fun SurfaceCalibrationScreen(container: AppContainer, onBack: () -> Unit) {
phase = CapturePhase.READY phase = CapturePhase.READY
stage = Stage.OVERVIEW stage = Stage.OVERVIEW
justSaved = true justSaved = true
scope.launch { container.settings.setSurfaceCalibration(calibration) } // App-owned scope: the write must survive an immediate Done/back.
container.applicationScope.launch { container.settings.setSurfaceCalibration(calibration) }
} }
} }
@@ -151,7 +151,7 @@ fun SurfaceCalibrationScreen(container: AppContainer, onBack: () -> Unit) {
Text("SURFACE CALIBRATION", style = MaterialTheme.typography.labelSmall, color = LevelColors.TextDim) Text("SURFACE CALIBRATION", style = MaterialTheme.typography.labelSmall, color = LevelColors.TextDim)
if (stage == Stage.CAPTURING) { if (stage == Stage.CAPTURING) {
Text( Text(
text = if (mode == CalibrationMode.THOROUGH) "THOROUGH" else "SIMPLE", text = if (mode == CalibrationMode.THOROUGH) "DETAILED" else "STANDARD",
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = LevelColors.Amber, color = LevelColors.Amber,
) )
@@ -165,7 +165,7 @@ fun SurfaceCalibrationScreen(container: AppContainer, onBack: () -> Unit) {
calibration = existingCalibration, calibration = existingCalibration,
justSaved = justSaved, justSaved = justSaved,
mode = mode, mode = mode,
onModeChange = { scope.launch { container.settings.setCalibrationMode(it) } }, onModeChange = { container.applicationScope.launch { container.settings.setCalibrationMode(it) } },
) )
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(12.dp)) { Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Button(onClick = ::startCalibrating, modifier = Modifier.fillMaxWidth()) { Button(onClick = ::startCalibrating, modifier = Modifier.fillMaxWidth()) {
@@ -176,7 +176,7 @@ fun SurfaceCalibrationScreen(container: AppContainer, onBack: () -> Unit) {
text = "Reset to phone defaults", text = "Reset to phone defaults",
onClick = { onClick = {
justSaved = false justSaved = false
scope.launch { container.settings.clearSurfaceCalibration() } container.applicationScope.launch { container.settings.clearSurfaceCalibration() }
}, },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) )
@@ -250,7 +250,12 @@ private fun OverviewContent(
Text( Text(
text = if (justSaved) "CALIBRATION SAVED" else "CURRENT CORRECTION", text = if (justSaved) "CALIBRATION SAVED" else "CURRENT CORRECTION",
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = if (justSaved) LevelColors.LimeLockText else LevelColors.TextDim, // Lime marks the confirmed/active state: just-saved, or an active calibration.
color = when {
justSaved -> LevelColors.LimeLockText
isCalibrated -> LevelColors.LimeLock
else -> LevelColors.TextDim
},
) )
Spacer(Modifier.height(10.dp)) Spacer(Modifier.height(10.dp))
if (isCalibrated) { if (isCalibrated) {
@@ -280,7 +285,7 @@ private fun OverviewContent(
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
) )
Spacer(Modifier.height(28.dp)) Spacer(Modifier.height(28.dp))
Text("THOROUGHNESS", style = MaterialTheme.typography.labelSmall, color = LevelColors.TextDim) Text("METHOD", style = MaterialTheme.typography.labelSmall, color = LevelColors.TextDim)
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
CalibrationMode.entries.forEachIndexed { index, entry -> CalibrationMode.entries.forEachIndexed { index, entry ->
@@ -289,15 +294,15 @@ private fun OverviewContent(
onClick = { onModeChange(entry) }, onClick = { onModeChange(entry) },
shape = SegmentedButtonDefaults.itemShape(index, CalibrationMode.entries.size), shape = SegmentedButtonDefaults.itemShape(index, CalibrationMode.entries.size),
) { ) {
Text(if (entry == CalibrationMode.SIMPLE) "Simple · 2 pt" else "Thorough · 4 pt") Text(if (entry == CalibrationMode.SIMPLE) "Standard" else "Detailed")
} }
} }
} }
Text( Text(
text = if (mode == CalibrationMode.THOROUGH) { text = if (mode == CalibrationMode.THOROUGH) {
"Four quarter-turns. Slower, but averages out more error." "Four positions, a quarter-turn apart. Averages more sensor noise and cross-checks the turn."
} else { } else {
"Two positions, half a turn apart. Quick and dependable." "Two positions, a half-turn apart. Quick and dependable."
}, },
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = LevelColors.TextDim, color = LevelColors.TextDim,
@@ -23,13 +23,11 @@ import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.onthelevel.AppContainer import com.onthelevel.AppContainer
import com.onthelevel.core.design.KeepScreenOn
import com.onthelevel.core.design.LevelColors import com.onthelevel.core.design.LevelColors
import com.onthelevel.core.settings.MeasurementUnits import com.onthelevel.core.settings.MeasurementUnits
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -45,9 +43,8 @@ fun SettingsScreen(
onOpenSurfaceCalibration: () -> Unit, onOpenSurfaceCalibration: () -> Unit,
onBack: () -> Unit, onBack: () -> Unit,
) { ) {
KeepScreenOn() // A preferences list should time out normally — no KeepScreenOn here (calibration
val scope = rememberCoroutineScope() // and the live tool screens keep the screen awake themselves).
val units by container.settings.measurementUnits val units by container.settings.measurementUnits
.collectAsStateWithLifecycle(initialValue = MeasurementUnits.METRIC) .collectAsStateWithLifecycle(initialValue = MeasurementUnits.METRIC)
val reducedMotion by container.settings.reducedMotion val reducedMotion by container.settings.reducedMotion
@@ -78,7 +75,7 @@ fun SettingsScreen(
MeasurementUnits.entries.forEachIndexed { index, entry -> MeasurementUnits.entries.forEachIndexed { index, entry ->
SegmentedButton( SegmentedButton(
selected = units == entry, selected = units == entry,
onClick = { scope.launch { container.settings.setMeasurementUnits(entry) } }, onClick = { container.applicationScope.launch { container.settings.setMeasurementUnits(entry) } },
shape = SegmentedButtonDefaults.itemShape(index, MeasurementUnits.entries.size), shape = SegmentedButtonDefaults.itemShape(index, MeasurementUnits.entries.size),
) { ) {
Text(if (entry == MeasurementUnits.METRIC) "Metric" else "Imperial") Text(if (entry == MeasurementUnits.METRIC) "Metric" else "Imperial")
@@ -90,16 +87,16 @@ fun SettingsScreen(
SettingsSection("Feedback & motion") { SettingsSection("Feedback & motion") {
ToggleRow( ToggleRow(
label = "Haptic feedback", label = "Haptic feedback",
description = "A gentle buzz when a surface locks level.", description = "A gentle buzz when the level locks.",
checked = hapticsEnabled, checked = hapticsEnabled,
onCheckedChange = { scope.launch { container.settings.setHapticsEnabled(it) } }, onCheckedChange = { container.applicationScope.launch { container.settings.setHapticsEnabled(it) } },
) )
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
ToggleRow( ToggleRow(
label = "Reduce motion", label = "Reduce motion",
description = "Snap the bubble instead of springing it.", description = "Snap the bubble instead of springing it.",
checked = reducedMotion, checked = reducedMotion,
onCheckedChange = { scope.launch { container.settings.setReducedMotion(it) } }, onCheckedChange = { container.applicationScope.launch { container.settings.setReducedMotion(it) } },
) )
} }
} }
@@ -17,7 +17,7 @@ import androidx.compose.ui.unit.dp
import com.onthelevel.core.design.LevelColors import com.onthelevel.core.design.LevelColors
@Composable @Composable
fun ToolsScreen(onOpenRuler: () -> Unit) { fun ToolsScreen(onOpenRuler: () -> Unit, onOpenSettings: () -> Unit) {
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@@ -33,14 +33,9 @@ fun ToolsScreen(onOpenRuler: () -> Unit) {
onClick = onOpenRuler, onClick = onOpenRuler,
) )
ToolCard( ToolCard(
title = "Calibration", title = "Settings",
subtitle = "Two-sample level calibration — coming with the feature build", subtitle = "Calibration, units, feedback, and motion",
onClick = { /* TODO(feature): guided calibration flow */ }, onClick = onOpenSettings,
)
ToolCard(
title = "Units & Feedback",
subtitle = "Haptics, audio cue, reduced motion — coming with the feature build",
onClick = { /* TODO(feature): preferences UI over SettingsRepository */ },
) )
} }
} }
@@ -3,6 +3,8 @@ package com.onthelevel.core.sensors
import org.junit.Assert.assertFalse import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
import kotlin.math.cos
import kotlin.math.sin
class SurfaceCalibrationValidationTest { class SurfaceCalibrationValidationTest {
@@ -17,4 +19,37 @@ class SurfaceCalibrationValidationTest {
assertFalse(SurfaceCalibrationValidation.isAcceptable(SurfaceCalibration(3.01, 0.0))) assertFalse(SurfaceCalibrationValidation.isAcceptable(SurfaceCalibration(3.01, 0.0)))
assertFalse(SurfaceCalibrationValidation.isAcceptable(SurfaceCalibration(2.5, 2.5))) assertFalse(SurfaceCalibrationValidation.isAcceptable(SurfaceCalibration(2.5, 2.5)))
} }
// A 5° surface is the worst permitted case (bias magnitude ≤ 5° is allowed to reach
// the mean). biasPitch/Roll are the fixed device bias; the true tilt rotates with
// the phone about the surface normal.
private val biasPitch = 0.3
private val biasRoll = -0.2
private val tilt = 5.0
private fun sampleAt(rotationDegrees: Double) = CapturedSurfaceSample(
pitchDegrees = biasPitch + tilt * cos(Math.toRadians(rotationDegrees)),
rollDegrees = biasRoll + tilt * sin(Math.toRadians(rotationDegrees)),
)
@Test
fun `clean four-point turn is consistent`() {
val clean = listOf(sampleAt(0.0), sampleAt(90.0), sampleAt(180.0), sampleAt(270.0))
assertTrue(SurfaceCalibrationValidation.isRotationConsistent(clean))
}
@Test
fun `sloppy four-point turn is rejected`() {
// Second position over-rotated 10° (100° instead of 90°). The (90,270) pair no
// longer cancels, so the two independent bias estimates diverge past tolerance —
// a malformed rotation the 3° magnitude guard alone cannot catch.
val sloppy = listOf(sampleAt(0.0), sampleAt(100.0), sampleAt(180.0), sampleAt(270.0))
assertFalse(SurfaceCalibrationValidation.isRotationConsistent(sloppy))
}
@Test
fun `two-point set has no cross-check and is always consistent`() {
val twoPoint = listOf(CapturedSurfaceSample(0.8, -0.1), CapturedSurfaceSample(-0.2, 0.5))
assertTrue(SurfaceCalibrationValidation.isRotationConsistent(twoPoint))
}
} }