diff --git a/README.md b/README.md
new file mode 100644
index 0000000..0e0f11a
--- /dev/null
+++ b/README.md
@@ -0,0 +1,42 @@
+# On the Level
+
+Offline-first Android pocket toolset: a trustworthy level, angle meter, and
+calibrated screen ruler (Pro). Kotlin + Jetpack Compose, single module, no backend.
+
+- **[BRIEF.md](BRIEF.md)** — the authoritative v1 product & technical brief
+- **[AUDIT.md](AUDIT.md)** — audit of the brief; the amendments it lists are incorporated
+- **resources/** — concept board (visual direction)
+
+## Building
+
+Open in Android Studio, or:
+
+```sh
+./gradlew assembleDebug # build
+./gradlew testDebugUnitTest # measurement-math unit tests
+```
+
+## Scaffold status
+
+The project skeleton follows BRIEF.md §Sensor and measurement architecture.
+Implemented and tested:
+
+- `core/sensors` — sensor selection (game rotation vector → gravity → low-passed
+ accelerometer), device-frame gravity stream, and the pure measurement math:
+ orientation mapping, two-sample per-mode calibration, EMA smoothing,
+ lock hysteresis/dwell/debounce, display deadband. All unit-tested.
+- `core/settings` — DataStore preferences and per-mode calibration persistence.
+- `core/billing` — entitlement interface with a stub (free-tier) implementation.
+- `feature/level`, `feature/angle` — live numeric scaffolds wired to the real
+ sensor pipeline, with manual Surface|Edge selection, lock state, hold-to-zero.
+- `feature/ruler`, `feature/tools` — static placeholders.
+
+Not yet implemented (deliberately — see TODO markers):
+
+- The tactile vial visuals and the spring-animated **display value** (value 3 of 3
+ in the brief); scaffold screens render the deadbanded stable value directly.
+- Guided calibration flow UI (math and persistence are done).
+- Edge-mode readout counter-rotation on the portrait-locked activity.
+- Play Billing implementation of `ProEntitlementRepository`.
+- Screen ruler measurement UI and per-display scale persistence.
+- Audio cue, reduced-motion behaviors, preferences UI.
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
new file mode 100644
index 0000000..919b797
--- /dev/null
+++ b/app/build.gradle.kts
@@ -0,0 +1,73 @@
+plugins {
+ alias(libs.plugins.android.application)
+ alias(libs.plugins.kotlin.compose)
+}
+
+android {
+ namespace = "com.onthelevel"
+ compileSdk = 37
+
+ defaultConfig {
+ applicationId = "com.jsjdesigns.onthelevel"
+ minSdk = 26
+ targetSdk = 37
+ versionCode = 1
+ versionName = "0.1"
+
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ vectorDrawables { useSupportLibrary = true }
+ }
+
+ buildTypes {
+ release {
+ isMinifyEnabled = false
+ proguardFiles(
+ getDefaultProguardFile("proguard-android-optimize.txt"),
+ "proguard-rules.pro"
+ )
+ }
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_11
+ targetCompatibility = JavaVersion.VERSION_11
+ }
+
+ buildFeatures {
+ compose = true
+ }
+
+ packaging {
+ resources {
+ excludes += "/META-INF/{AL2.0,LGPL2.1}"
+ }
+ }
+}
+
+dependencies {
+ implementation(libs.androidx.core.ktx)
+ implementation(libs.androidx.activity.compose)
+ implementation(libs.androidx.lifecycle.runtime.ktx)
+ implementation(libs.androidx.lifecycle.runtime.compose)
+ implementation(libs.androidx.lifecycle.viewmodel.compose)
+
+ implementation(platform(libs.androidx.compose.bom))
+ implementation(libs.androidx.compose.ui)
+ implementation(libs.androidx.compose.ui.graphics)
+ implementation(libs.androidx.compose.ui.tooling.preview)
+ implementation(libs.androidx.compose.material3)
+ implementation(libs.androidx.compose.material.icons.extended)
+
+ implementation(libs.androidx.navigation.compose)
+ implementation(libs.androidx.datastore.preferences)
+ // Declared now so core/billing's real implementation lands without build changes (BRIEF.md §Monetization).
+ implementation(libs.billing.ktx)
+
+ debugImplementation(libs.androidx.compose.ui.tooling)
+
+ testImplementation(libs.junit)
+ testImplementation(libs.kotlinx.coroutines.test)
+ androidTestImplementation(libs.androidx.junit)
+ androidTestImplementation(libs.androidx.espresso.core)
+ androidTestImplementation(platform(libs.androidx.compose.bom))
+}
diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro
new file mode 100644
index 0000000..fb164d6
--- /dev/null
+++ b/app/proguard-rules.pro
@@ -0,0 +1 @@
+# Add project specific ProGuard rules here.
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..a5298ec
--- /dev/null
+++ b/app/src/main/AndroidManifest.xml
@@ -0,0 +1,31 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/java/com/onthelevel/MainActivity.kt b/app/src/main/java/com/onthelevel/MainActivity.kt
new file mode 100644
index 0000000..f3741e0
--- /dev/null
+++ b/app/src/main/java/com/onthelevel/MainActivity.kt
@@ -0,0 +1,98 @@
+package com.onthelevel
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.Adjust
+import androidx.compose.material.icons.outlined.Handyman
+import androidx.compose.material.icons.outlined.SquareFoot
+import androidx.compose.material3.Icon
+import androidx.compose.material3.NavigationBar
+import androidx.compose.material3.NavigationBarItem
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.foundation.layout.padding
+import androidx.navigation.NavGraph.Companion.findStartDestination
+import androidx.navigation.compose.NavHost
+import androidx.navigation.compose.composable
+import androidx.navigation.compose.currentBackStackEntryAsState
+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.ruler.RulerScreen
+import com.onthelevel.feature.tools.ToolsScreen
+
+class MainActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ enableEdgeToEdge()
+ super.onCreate(savedInstanceState)
+ val container = (application as OnTheLevelApp).container
+ setContent {
+ OnTheLevelTheme {
+ AppRoot(container)
+ }
+ }
+ }
+}
+
+private data class TopDestination(
+ val route: String,
+ val label: String,
+ val icon: ImageVector,
+)
+
+private val topDestinations = listOf(
+ TopDestination("level", "Level", Icons.Outlined.Adjust),
+ TopDestination("angle", "Angle", Icons.Outlined.SquareFoot),
+ TopDestination("tools", "Tools", Icons.Outlined.Handyman),
+)
+
+@Composable
+private fun AppRoot(container: AppContainer) {
+ val navController = rememberNavController()
+ val backStackEntry by navController.currentBackStackEntryAsState()
+ val currentRoute = backStackEntry?.destination?.route
+
+ Scaffold(
+ bottomBar = {
+ NavigationBar {
+ topDestinations.forEach { destination ->
+ NavigationBarItem(
+ selected = currentRoute == destination.route,
+ onClick = {
+ navController.navigate(destination.route) {
+ popUpTo(navController.graph.findStartDestination().id) {
+ saveState = true
+ }
+ launchSingleTop = true
+ restoreState = true
+ }
+ },
+ icon = { Icon(destination.icon, contentDescription = null) },
+ label = { Text(destination.label) },
+ )
+ }
+ }
+ },
+ ) { padding ->
+ NavHost(
+ navController = navController,
+ startDestination = "level",
+ modifier = Modifier.padding(padding),
+ ) {
+ composable("level") { LevelScreen(container) }
+ composable("angle") { AngleScreen(container) }
+ composable("tools") {
+ ToolsScreen(onOpenRuler = { navController.navigate("ruler") })
+ }
+ composable("ruler") { RulerScreen(container) }
+ }
+ }
+}
diff --git a/app/src/main/java/com/onthelevel/OnTheLevelApp.kt b/app/src/main/java/com/onthelevel/OnTheLevelApp.kt
new file mode 100644
index 0000000..e07d62d
--- /dev/null
+++ b/app/src/main/java/com/onthelevel/OnTheLevelApp.kt
@@ -0,0 +1,25 @@
+package com.onthelevel
+
+import android.app.Application
+import android.content.Context
+import com.onthelevel.core.billing.ProEntitlementRepository
+import com.onthelevel.core.billing.StubProEntitlementRepository
+import com.onthelevel.core.sensors.AndroidSensorSource
+import com.onthelevel.core.sensors.SensorSource
+import com.onthelevel.core.settings.SettingsRepository
+
+/**
+ * Manual application container — deliberately no DI framework for an app this size
+ * (BRIEF.md §Sensor and measurement architecture).
+ */
+class AppContainer(context: Context) {
+ private val appContext = context.applicationContext
+
+ val sensorSource: SensorSource by lazy { AndroidSensorSource(appContext) }
+ val settings: SettingsRepository by lazy { SettingsRepository(appContext) }
+ val entitlement: ProEntitlementRepository by lazy { StubProEntitlementRepository() }
+}
+
+class OnTheLevelApp : Application() {
+ val container: AppContainer by lazy { AppContainer(this) }
+}
diff --git a/app/src/main/java/com/onthelevel/core/billing/Entitlement.kt b/app/src/main/java/com/onthelevel/core/billing/Entitlement.kt
new file mode 100644
index 0000000..5ff5c85
--- /dev/null
+++ b/app/src/main/java/com/onthelevel/core/billing/Entitlement.kt
@@ -0,0 +1,55 @@
+package com.onthelevel.core.billing
+
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+
+/**
+ * Pro entitlement state (BRIEF.md §Monetization). One permanent non-consumable
+ * product; no subscription.
+ */
+data class Entitlement(
+ val isPro: Boolean,
+ val source: Source,
+) {
+ enum class Source {
+ /** No confirmation yet — the default, and always safe: free tools are complete. */
+ NONE,
+ /** Confirmed by Google Play this session. */
+ PLAY_CONFIRMED,
+ /** Previously Play-confirmed, restored from local cache (offline job-site use). */
+ CACHED,
+ }
+
+ companion object {
+ val FREE = Entitlement(isPro = false, source = Source.NONE)
+ }
+}
+
+/**
+ * The ONLY doorway between billing and the rest of the app. Feature code observes
+ * [entitlement]; nothing outside core/billing may touch Play Billing types
+ * (AUDIT.md: Pro boundary containment).
+ */
+interface ProEntitlementRepository {
+ val entitlement: StateFlow
+
+ /** Re-query owned purchases when possible (app resume, purchase flow completion). */
+ suspend fun refresh()
+}
+
+/**
+ * Scaffold stand-in: everyone is free tier. Replaced by the Play Billing
+ * implementation, which must:
+ * - initialize lazily/async — free tools never block on Play services
+ * - cache a Play-confirmed entitlement in DataStore for offline use
+ * - never grant from an arbitrary preference value or a PENDING purchase
+ */
+class StubProEntitlementRepository : ProEntitlementRepository {
+ private val state = MutableStateFlow(Entitlement.FREE)
+ override val entitlement: StateFlow = state.asStateFlow()
+ override suspend fun refresh() = Unit
+}
+
+// TODO(billing): PlayBillingEntitlementRepository against the declared billing-ktx
+// dependency, product id "on_the_level_pro", per the contract above.
diff --git a/app/src/main/java/com/onthelevel/core/design/KeepScreenOn.kt b/app/src/main/java/com/onthelevel/core/design/KeepScreenOn.kt
new file mode 100644
index 0000000..939c494
--- /dev/null
+++ b/app/src/main/java/com/onthelevel/core/design/KeepScreenOn.kt
@@ -0,0 +1,18 @@
+package com.onthelevel.core.design
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.ui.platform.LocalView
+
+/**
+ * Levels are used hands-free; the screen must not time out mid-measurement
+ * (BRIEF.md §Level). Scoped to the composable, so leaving the screen releases it.
+ */
+@Composable
+fun KeepScreenOn() {
+ val view = LocalView.current
+ DisposableEffect(view) {
+ view.keepScreenOn = true
+ onDispose { view.keepScreenOn = false }
+ }
+}
diff --git a/app/src/main/java/com/onthelevel/core/design/Theme.kt b/app/src/main/java/com/onthelevel/core/design/Theme.kt
new file mode 100644
index 0000000..352356f
--- /dev/null
+++ b/app/src/main/java/com/onthelevel/core/design/Theme.kt
@@ -0,0 +1,85 @@
+package com.onthelevel.core.design
+
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Typography
+import androidx.compose.material3.darkColorScheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.sp
+
+/**
+ * Palette from the concept board (resources/Bubble Level Concepts.dc.html):
+ * deep graphite glass, warm amber spirit fluid, lime for the locked state.
+ * The design is dark-only in v1 — this is an instrument, not a document.
+ * Per BRIEF.md, color never carries state alone; labels and numbers always accompany it.
+ */
+object LevelColors {
+ val Graphite = Color(0xFF0A0A0B)
+ val GraphiteRaised = Color(0xFF15181D)
+ val PanelStroke = Color(0x12FFFFFF)
+ val Panel = Color(0x0AFFFFFF)
+
+ val Amber = Color(0xFFFFC44E)
+ val AmberHighlight = Color(0xFFFFEEB0)
+ val AmberDeep = Color(0xFFE0961E)
+
+ val LimeLock = Color(0xFFCBEF5C)
+ val LimeLockText = Color(0xFFEAFFC2)
+
+ val Cream = Color(0xFFEDEBE6)
+ val TextPrimary = Color(0xFFF5F4F1)
+ val TextDim = Color(0x6BFFFFFF)
+ val TextFaint = Color(0x55FFFFFF)
+}
+
+private val DarkScheme = darkColorScheme(
+ primary = LevelColors.Amber,
+ onPrimary = LevelColors.Graphite,
+ secondary = LevelColors.LimeLock,
+ onSecondary = LevelColors.Graphite,
+ background = LevelColors.Graphite,
+ onBackground = LevelColors.TextPrimary,
+ surface = LevelColors.GraphiteRaised,
+ onSurface = LevelColors.TextPrimary,
+ surfaceVariant = LevelColors.GraphiteRaised,
+ onSurfaceVariant = LevelColors.TextDim,
+ outline = LevelColors.PanelStroke,
+)
+
+// TODO(design): bundle Space Grotesk + IBM Plex Mono per the concept board.
+// Until then: system sans for labels, platform monospace for all numeric readouts
+// so digits don't jitter horizontally as values change.
+val ReadoutFontFamily = FontFamily.Monospace
+
+private val LevelTypography = Typography(
+ displayLarge = TextStyle(
+ fontFamily = ReadoutFontFamily,
+ fontWeight = FontWeight.Light,
+ fontSize = 96.sp,
+ letterSpacing = (-2).sp,
+ ),
+ headlineMedium = TextStyle(
+ fontFamily = ReadoutFontFamily,
+ fontWeight = FontWeight.Normal,
+ fontSize = 28.sp,
+ ),
+ labelSmall = TextStyle(
+ fontWeight = FontWeight.SemiBold,
+ fontSize = 11.sp,
+ letterSpacing = 2.sp,
+ ),
+)
+
+@Composable
+fun OnTheLevelTheme(content: @Composable () -> Unit) {
+ // Dark-only by design; isSystemInDarkTheme() intentionally unused in v1.
+ MaterialTheme(
+ colorScheme = DarkScheme,
+ typography = LevelTypography,
+ content = content,
+ )
+}
diff --git a/app/src/main/java/com/onthelevel/core/sensors/AndroidSensorSource.kt b/app/src/main/java/com/onthelevel/core/sensors/AndroidSensorSource.kt
new file mode 100644
index 0000000..96602ad
--- /dev/null
+++ b/app/src/main/java/com/onthelevel/core/sensors/AndroidSensorSource.kt
@@ -0,0 +1,101 @@
+package com.onthelevel.core.sensors
+
+import android.content.Context
+import android.hardware.Sensor
+import android.hardware.SensorEvent
+import android.hardware.SensorEventListener
+import android.hardware.SensorManager
+import kotlinx.coroutines.channels.awaitClose
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.callbackFlow
+import kotlinx.coroutines.flow.emptyFlow
+
+/**
+ * Sensor selection order (BRIEF.md): GAME_ROTATION_VECTOR → GRAVITY → low-passed
+ * ACCELEROMETER. Game rotation vector is deliberate — it excludes the magnetometer,
+ * which lies near the steel this app is used against (AUDIT.md finding 1). No
+ * heading is needed; only the gravity direction matters.
+ */
+class AndroidSensorSource(context: Context) : SensorSource {
+
+ private val sensorManager: SensorManager? =
+ context.getSystemService(SensorManager::class.java)
+
+ private val selected: Pair? = sensorManager?.let { sm ->
+ sm.getDefaultSensor(Sensor.TYPE_GAME_ROTATION_VECTOR)
+ ?.let { it to SensorSource.Kind.GAME_ROTATION_VECTOR }
+ ?: sm.getDefaultSensor(Sensor.TYPE_GRAVITY)
+ ?.let { it to SensorSource.Kind.GRAVITY }
+ ?: sm.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)
+ ?.let { it to SensorSource.Kind.ACCELEROMETER }
+ }
+
+ override val isAvailable: Boolean = selected != null
+
+ override val kind: SensorSource.Kind = selected?.second ?: SensorSource.Kind.NONE
+
+ override val gravity: Flow = if (selected == null || sensorManager == null) {
+ emptyFlow()
+ } else {
+ callbackFlow {
+ val (sensor, sensorKind) = selected
+ val rotationMatrix = FloatArray(9)
+ // Raw-accelerometer fallback only: strip linear acceleration before the
+ // measurement-layer EMA sees the sample. TODO(tune) against recorded traces.
+ var lp: Triple? = null
+
+ val listener = object : SensorEventListener {
+ override fun onSensorChanged(event: SensorEvent) {
+ val sample = when (sensorKind) {
+ SensorSource.Kind.GAME_ROTATION_VECTOR -> {
+ SensorManager.getRotationMatrixFromVector(rotationMatrix, event.values)
+ // R maps device → world; world-up expressed in the device frame
+ // is R's third row. Scale to standard gravity.
+ GravitySample(
+ x = rotationMatrix[6] * STANDARD_GRAVITY,
+ y = rotationMatrix[7] * STANDARD_GRAVITY,
+ z = rotationMatrix[8] * STANDARD_GRAVITY,
+ timestampNanos = event.timestamp,
+ )
+ }
+ SensorSource.Kind.GRAVITY -> GravitySample(
+ x = event.values[0].toDouble(),
+ y = event.values[1].toDouble(),
+ z = event.values[2].toDouble(),
+ timestampNanos = event.timestamp,
+ )
+ else -> {
+ val prev = lp
+ val next = if (prev == null) {
+ Triple(
+ event.values[0].toDouble(),
+ event.values[1].toDouble(),
+ event.values[2].toDouble(),
+ )
+ } else {
+ Triple(
+ prev.first + ACCEL_LP_ALPHA * (event.values[0] - prev.first),
+ prev.second + ACCEL_LP_ALPHA * (event.values[1] - prev.second),
+ prev.third + ACCEL_LP_ALPHA * (event.values[2] - prev.third),
+ )
+ }
+ lp = next
+ GravitySample(next.first, next.second, next.third, event.timestamp)
+ }
+ }
+ trySend(sample)
+ }
+
+ override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) = Unit
+ }
+
+ sensorManager.registerListener(listener, sensor, SensorManager.SENSOR_DELAY_GAME)
+ awaitClose { sensorManager.unregisterListener(listener) }
+ }
+ }
+
+ private companion object {
+ const val STANDARD_GRAVITY = 9.80665
+ const val ACCEL_LP_ALPHA = 0.15
+ }
+}
diff --git a/app/src/main/java/com/onthelevel/core/sensors/DisplayDeadband.kt b/app/src/main/java/com/onthelevel/core/sensors/DisplayDeadband.kt
new file mode 100644
index 0000000..1252bbe
--- /dev/null
+++ b/app/src/main/java/com/onthelevel/core/sensors/DisplayDeadband.kt
@@ -0,0 +1,34 @@
+package com.onthelevel.core.sensors
+
+import kotlin.math.abs
+import kotlin.math.roundToInt
+
+/**
+ * Quantizes a stable measurement to 0.1° for display, with hysteresis so the readout
+ * doesn't flicker between adjacent values when the input sits on a bucket boundary
+ * (BRIEF.md: "0.1° resolution and a display deadband").
+ *
+ * A displayed value of 0.1° owns the interval [0.05, 0.15]; the readout only moves
+ * once the input leaves that interval by more than [hysteresisMarginDegrees].
+ */
+class DisplayDeadband(
+ private val resolutionDegrees: Double = 0.1,
+ private val hysteresisMarginDegrees: Double = 0.03,
+) {
+ private var displayed: Double? = null
+
+ fun update(stableDegrees: Double): Double {
+ val current = displayed
+ val next = if (current == null ||
+ abs(stableDegrees - current) > resolutionDegrees / 2 + hysteresisMarginDegrees
+ ) {
+ (stableDegrees / resolutionDegrees).roundToInt() * resolutionDegrees
+ } else {
+ current
+ }
+ displayed = next
+ return next
+ }
+
+ fun reset() { displayed = null }
+}
diff --git a/app/src/main/java/com/onthelevel/core/sensors/Ema.kt b/app/src/main/java/com/onthelevel/core/sensors/Ema.kt
new file mode 100644
index 0000000..403f489
--- /dev/null
+++ b/app/src/main/java/com/onthelevel/core/sensors/Ema.kt
@@ -0,0 +1,26 @@
+package com.onthelevel.core.sensors
+
+import kotlin.math.exp
+
+/**
+ * Time-constant-based exponential moving average — the "stable calibrated measurement"
+ * filter (BRIEF.md value 2 of 3). Using a time constant instead of a fixed alpha keeps
+ * smoothing identical across devices with different sensor rates.
+ */
+class Ema(private val tauSeconds: Double) {
+ private var value: Double? = null
+
+ fun update(sample: Double, dtSeconds: Double): Double {
+ val prev = value
+ val next = if (prev == null || dtSeconds <= 0.0) {
+ sample
+ } else {
+ val alpha = 1.0 - exp(-dtSeconds / tauSeconds)
+ prev + alpha * (sample - prev)
+ }
+ value = next
+ return next
+ }
+
+ fun reset() { value = null }
+}
diff --git a/app/src/main/java/com/onthelevel/core/sensors/GravitySample.kt b/app/src/main/java/com/onthelevel/core/sensors/GravitySample.kt
new file mode 100644
index 0000000..5b0b833
--- /dev/null
+++ b/app/src/main/java/com/onthelevel/core/sensors/GravitySample.kt
@@ -0,0 +1,16 @@
+package com.onthelevel.core.sensors
+
+/**
+ * Gravity (reaction) vector in the DEVICE coordinate frame, in m/s².
+ * Android convention: x → right edge, y → top edge, z → out of the screen.
+ * A phone lying screen-up on a level surface reads approximately (0, 0, +9.81).
+ */
+data class GravitySample(
+ val x: Double,
+ val y: Double,
+ val z: Double,
+ val timestampNanos: Long,
+)
+
+/** The two physical orientations v1 supports (BRIEF.md). Selected manually — never auto-switched. */
+enum class LevelMode { SURFACE, EDGE }
diff --git a/app/src/main/java/com/onthelevel/core/sensors/LockDetector.kt b/app/src/main/java/com/onthelevel/core/sensors/LockDetector.kt
new file mode 100644
index 0000000..e0f3d83
--- /dev/null
+++ b/app/src/main/java/com/onthelevel/core/sensors/LockDetector.kt
@@ -0,0 +1,67 @@
+package com.onthelevel.core.sensors
+
+import kotlin.math.abs
+
+/**
+ * Level-lock state machine with hysteresis, dwell, and haptic debounce (BRIEF.md
+ * §Sensor and measurement architecture). Operates ONLY on the stable calibrated
+ * measurement — the same value the numeric readout shows. The lock must never
+ * disagree with the number on screen.
+ *
+ * Time is injected (callers pass `nowMillis`) so transitions are unit-testable.
+ */
+class LockDetector(
+ private val enterThresholdDegrees: Double = 0.2,
+ private val exitThresholdDegrees: Double = 0.35,
+ private val dwellMillis: Long = 400,
+ private val feedbackDebounceMillis: Long = 3_000,
+) {
+ init {
+ require(exitThresholdDegrees > enterThresholdDegrees) {
+ "Hysteresis requires exit > enter threshold"
+ }
+ }
+
+ data class Result(
+ val isLocked: Boolean,
+ /** True exactly once per lock acquisition, and only outside the debounce window. */
+ val fireFeedback: Boolean,
+ )
+
+ private var locked = false
+ private var withinEnterSinceMillis: Long? = null
+ private var lastFeedbackAtMillis: Long? = null
+
+ fun update(stableTiltDegrees: Double, nowMillis: Long): Result {
+ val magnitude = abs(stableTiltDegrees)
+ var fire = false
+
+ if (!locked) {
+ if (magnitude <= enterThresholdDegrees) {
+ val since = withinEnterSinceMillis ?: nowMillis.also { withinEnterSinceMillis = it }
+ if (nowMillis - since >= dwellMillis) {
+ locked = true
+ val last = lastFeedbackAtMillis
+ if (last == null || nowMillis - last >= feedbackDebounceMillis) {
+ fire = true
+ lastFeedbackAtMillis = nowMillis
+ }
+ }
+ } else {
+ withinEnterSinceMillis = null
+ }
+ } else if (magnitude >= exitThresholdDegrees) {
+ locked = false
+ withinEnterSinceMillis = null
+ }
+
+ return Result(isLocked = locked, fireFeedback = fire)
+ }
+
+ fun reset() {
+ locked = false
+ withinEnterSinceMillis = null
+ // lastFeedbackAtMillis survives reset on purpose: switching modes must not
+ // defeat the haptic debounce.
+ }
+}
diff --git a/app/src/main/java/com/onthelevel/core/sensors/OrientationMath.kt b/app/src/main/java/com/onthelevel/core/sensors/OrientationMath.kt
new file mode 100644
index 0000000..bbc9363
--- /dev/null
+++ b/app/src/main/java/com/onthelevel/core/sensors/OrientationMath.kt
@@ -0,0 +1,66 @@
+package com.onthelevel.core.sensors
+
+import kotlin.math.abs
+import kotlin.math.acos
+import kotlin.math.asin
+import kotlin.math.atan2
+import kotlin.math.sqrt
+import kotlin.math.tan
+
+/**
+ * Pure measurement math. No Android types — everything here is unit-testable
+ * with synthetic and recorded gravity vectors (BRIEF.md §Testability).
+ *
+ * Angle sign conventions (documented so calibration and UI agree):
+ * - Surface pitch: positive when the TOP edge of the device is higher.
+ * - Surface roll: positive when the RIGHT edge of the device is higher.
+ * - Edge level: positive when the end the device Y axis points toward is higher.
+ */
+object OrientationMath {
+
+ /** Angle between gravity and the screen normal — the single honest "how far off flat" number. */
+ fun surfaceTiltMagnitudeDegrees(g: GravitySample): Double {
+ val n = norm(g)
+ if (n == 0.0) return 0.0
+ return Math.toDegrees(acos((g.z / n).coerceIn(-1.0, 1.0)))
+ }
+
+ fun surfacePitchDegrees(g: GravitySample): Double =
+ Math.toDegrees(atan2(g.y, g.z))
+
+ fun surfaceRollDegrees(g: GravitySample): Double =
+ Math.toDegrees(atan2(g.x, g.z))
+
+ /**
+ * Edge mode: device standing on a long edge (gravity mostly along ±x).
+ * The reading is the deviation of the resting edge from horizontal —
+ * the component of gravity along the device Y axis.
+ */
+ fun edgeLevelDegrees(g: GravitySample): Double {
+ val n = norm(g)
+ if (n == 0.0) return 0.0
+ return Math.toDegrees(asin((g.y / n).coerceIn(-1.0, 1.0)))
+ }
+
+ /** Secondary edge reading: how far the screen leans from vertical (plumb). */
+ fun edgePlumbLeanDegrees(g: GravitySample): Double {
+ val n = norm(g)
+ if (n == 0.0) return 0.0
+ return Math.toDegrees(asin((g.z / n).coerceIn(-1.0, 1.0)))
+ }
+
+ /**
+ * Percent grade = tan(angle) × 100. Returns signed infinity at/beyond
+ * [VERTICAL_GRADE_CUTOFF_DEGREES]; UI renders that as "∞" (BRIEF.md §Angle).
+ */
+ fun percentGrade(angleDegrees: Double): Double {
+ if (abs(angleDegrees) >= VERTICAL_GRADE_CUTOFF_DEGREES) {
+ return if (angleDegrees > 0) Double.POSITIVE_INFINITY else Double.NEGATIVE_INFINITY
+ }
+ return tan(Math.toRadians(angleDegrees)) * 100.0
+ }
+
+ const val VERTICAL_GRADE_CUTOFF_DEGREES = 89.5
+
+ private fun norm(g: GravitySample): Double = sqrt(g.x * g.x + g.y * g.y + g.z * g.z)
+}
diff --git a/app/src/main/java/com/onthelevel/core/sensors/SensorSource.kt b/app/src/main/java/com/onthelevel/core/sensors/SensorSource.kt
new file mode 100644
index 0000000..d8a547d
--- /dev/null
+++ b/app/src/main/java/com/onthelevel/core/sensors/SensorSource.kt
@@ -0,0 +1,24 @@
+package com.onthelevel.core.sensors
+
+import kotlinx.coroutines.flow.Flow
+
+/**
+ * The seam between Android sensor I/O and the pure measurement math. Tests inject
+ * fakes emitting synthetic or recorded traces (BRIEF.md §Testability).
+ */
+interface SensorSource {
+ /** False on devices with no usable tilt sensor: show the unsupported-device state. */
+ val isAvailable: Boolean
+
+ /** Which physical sensor backs [gravity]; surfaced in the header status line. */
+ val kind: Kind
+
+ /**
+ * Device-frame gravity stream. Cold: registering happens on collection and
+ * unregistering on cancellation, so lifecycle-aware collection automatically
+ * satisfies "sensors registered only while a relevant screen is foregrounded".
+ */
+ val gravity: Flow
+
+ enum class Kind { GAME_ROTATION_VECTOR, GRAVITY, ACCELEROMETER, NONE }
+}
diff --git a/app/src/main/java/com/onthelevel/core/sensors/TwoSampleCalibration.kt b/app/src/main/java/com/onthelevel/core/sensors/TwoSampleCalibration.kt
new file mode 100644
index 0000000..f22b4d2
--- /dev/null
+++ b/app/src/main/java/com/onthelevel/core/sensors/TwoSampleCalibration.kt
@@ -0,0 +1,51 @@
+package com.onthelevel.core.sensors
+
+/**
+ * Two-sample (180° flip) calibration, per mode (BRIEF.md §Measurement modes and calibration).
+ *
+ * Math: the surface's true tilt is fixed in the world frame; the device's own bias is fixed
+ * in the device frame. After rotating the device 180° about the CONTACT-PLANE NORMAL — on the
+ * same, unmoved surface — the true tilt appears negated in device readings while the bias
+ * does not move:
+ *
+ * reading₁ = tilt + bias
+ * reading₂ = -tilt + bias
+ * ⇒ bias = (reading₁ + reading₂) / 2
+ *
+ * This holds per axis for the small near-level angles calibration is used at. It is only
+ * valid if (a) the rotation is about the contact-plane normal and (b) the surface does not
+ * move between samples — the calibration UI must instruct exactly that.
+ *
+ * Biases are stored per mode and applied only to that mode's readings; a Surface
+ * calibration must never touch Edge readings (AUDIT.md finding 3).
+ */
+object TwoSampleCalibration {
+
+ /** Derive one axis's fixed device bias from two readings taken 180° apart. */
+ fun deriveBiasDegrees(reading1Degrees: Double, reading2Degrees: Double): Double =
+ (reading1Degrees + reading2Degrees) / 2.0
+
+ fun deriveSurface(
+ pitch1: Double, roll1: Double,
+ pitch2: Double, roll2: Double,
+ ): SurfaceCalibration = SurfaceCalibration(
+ pitchBiasDegrees = deriveBiasDegrees(pitch1, pitch2),
+ rollBiasDegrees = deriveBiasDegrees(roll1, roll2),
+ )
+
+ fun deriveEdge(level1: Double, level2: Double): EdgeCalibration =
+ EdgeCalibration(levelBiasDegrees = deriveBiasDegrees(level1, level2))
+}
+
+data class SurfaceCalibration(val pitchBiasDegrees: Double, val rollBiasDegrees: Double) {
+ fun applyToPitch(rawPitchDegrees: Double): Double = rawPitchDegrees - pitchBiasDegrees
+ fun applyToRoll(rawRollDegrees: Double): Double = rawRollDegrees - rollBiasDegrees
+
+ companion object { val NONE = SurfaceCalibration(0.0, 0.0) }
+}
+
+data class EdgeCalibration(val levelBiasDegrees: Double) {
+ fun applyToLevel(rawLevelDegrees: Double): Double = rawLevelDegrees - levelBiasDegrees
+
+ companion object { val NONE = EdgeCalibration(0.0) }
+}
diff --git a/app/src/main/java/com/onthelevel/core/settings/SettingsRepository.kt b/app/src/main/java/com/onthelevel/core/settings/SettingsRepository.kt
new file mode 100644
index 0000000..95dd7c6
--- /dev/null
+++ b/app/src/main/java/com/onthelevel/core/settings/SettingsRepository.kt
@@ -0,0 +1,75 @@
+package com.onthelevel.core.settings
+
+import android.content.Context
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.booleanPreferencesKey
+import androidx.datastore.preferences.core.doublePreferencesKey
+import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.preferencesDataStore
+import com.onthelevel.core.sensors.EdgeCalibration
+import com.onthelevel.core.sensors.SurfaceCalibration
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.map
+
+private val Context.dataStore: DataStore by preferencesDataStore(name = "settings")
+
+/**
+ * DataStore-backed preferences and calibration persistence (BRIEF.md core/settings).
+ * Calibration is stored PER MODE and applied only to that mode's readings.
+ */
+class SettingsRepository(context: Context) {
+
+ private val store = context.applicationContext.dataStore
+
+ val surfaceCalibration: Flow = store.data.map { prefs ->
+ SurfaceCalibration(
+ pitchBiasDegrees = prefs[Keys.SURFACE_PITCH_BIAS] ?: 0.0,
+ rollBiasDegrees = prefs[Keys.SURFACE_ROLL_BIAS] ?: 0.0,
+ )
+ }
+
+ val edgeCalibration: Flow = store.data.map { prefs ->
+ EdgeCalibration(levelBiasDegrees = prefs[Keys.EDGE_LEVEL_BIAS] ?: 0.0)
+ }
+
+ val hapticsEnabled: Flow = store.data.map { it[Keys.HAPTICS_ENABLED] ?: true }
+ val audioCueEnabled: Flow = store.data.map { it[Keys.AUDIO_CUE_ENABLED] ?: false }
+
+ /** In-app reduced-motion preference; the system animator-scale signal is respected separately. */
+ val reducedMotion: Flow = store.data.map { it[Keys.REDUCED_MOTION] ?: false }
+
+ suspend fun setSurfaceCalibration(calibration: SurfaceCalibration) {
+ store.edit {
+ it[Keys.SURFACE_PITCH_BIAS] = calibration.pitchBiasDegrees
+ it[Keys.SURFACE_ROLL_BIAS] = calibration.rollBiasDegrees
+ }
+ }
+
+ suspend fun setEdgeCalibration(calibration: EdgeCalibration) {
+ store.edit { it[Keys.EDGE_LEVEL_BIAS] = calibration.levelBiasDegrees }
+ }
+
+ suspend fun setHapticsEnabled(enabled: Boolean) {
+ store.edit { it[Keys.HAPTICS_ENABLED] = enabled }
+ }
+
+ suspend fun setAudioCueEnabled(enabled: Boolean) {
+ store.edit { it[Keys.AUDIO_CUE_ENABLED] = enabled }
+ }
+
+ suspend fun setReducedMotion(enabled: Boolean) {
+ store.edit { it[Keys.REDUCED_MOTION] = enabled }
+ }
+
+ // TODO(ruler): screen-ruler scale keyed by display identity/characteristics (BRIEF.md).
+
+ private object Keys {
+ val SURFACE_PITCH_BIAS = doublePreferencesKey("surface_pitch_bias_deg")
+ val SURFACE_ROLL_BIAS = doublePreferencesKey("surface_roll_bias_deg")
+ val EDGE_LEVEL_BIAS = doublePreferencesKey("edge_level_bias_deg")
+ val HAPTICS_ENABLED = booleanPreferencesKey("haptics_enabled")
+ val AUDIO_CUE_ENABLED = booleanPreferencesKey("audio_cue_enabled")
+ val REDUCED_MOTION = booleanPreferencesKey("reduced_motion")
+ }
+}
diff --git a/app/src/main/java/com/onthelevel/feature/angle/AngleScreen.kt b/app/src/main/java/com/onthelevel/feature/angle/AngleScreen.kt
new file mode 100644
index 0000000..4abed2a
--- /dev/null
+++ b/app/src/main/java/com/onthelevel/feature/angle/AngleScreen.kt
@@ -0,0 +1,146 @@
+package com.onthelevel.feature.angle
+
+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.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.platform.LocalView
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.compose.foundation.gestures.detectTapGestures
+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.Ema
+import com.onthelevel.core.sensors.OrientationMath
+import com.onthelevel.feature.level.formatDegrees
+import com.onthelevel.feature.level.performConfirmHaptic
+import kotlinx.coroutines.flow.map
+
+/**
+ * Scaffold Angle screen: live absolute/relative angle with hold-to-zero (always free).
+ * TODO(pro): target-angle alerts and saved named references behind the entitlement.
+ */
+@Composable
+fun AngleScreen(container: AppContainer) {
+ KeepScreenOn()
+
+ val sensorSource = container.sensorSource
+ if (!sensorSource.isAvailable) {
+ Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ Text("No usable tilt sensor on this device.", color = LevelColors.TextDim)
+ }
+ return
+ }
+
+ data class AngleReading(val tilt: Double, val pitch: Double, val roll: Double)
+
+ val readingFlow = remember {
+ val tiltEma = Ema(0.15)
+ val pitchEma = Ema(0.15)
+ val rollEma = Ema(0.15)
+ var lastNanos: Long? = null
+ sensorSource.gravity.map { g ->
+ val dt = lastNanos?.let { (g.timestampNanos - it) / 1e9 } ?: 0.0
+ lastNanos = g.timestampNanos
+ AngleReading(
+ tilt = tiltEma.update(OrientationMath.surfaceTiltMagnitudeDegrees(g), dt),
+ pitch = pitchEma.update(OrientationMath.surfacePitchDegrees(g), dt),
+ roll = rollEma.update(OrientationMath.surfaceRollDegrees(g), dt),
+ )
+ }
+ }
+ val reading by readingFlow.collectAsStateWithLifecycle(initialValue = null)
+
+ // Relative reference: "hold to zero" (BRIEF.md §Angle — always free).
+ var zeroReferenceDegrees by rememberSaveable { mutableStateOf(0.0) }
+ val view = LocalView.current
+
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(horizontal = 24.dp, vertical = 16.dp),
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ ) {
+ Text("ANGLE", style = MaterialTheme.typography.labelSmall, color = LevelColors.TextDim)
+ Text(
+ text = if (zeroReferenceDegrees != 0.0) "RELATIVE · HOLD TO RE-ZERO" else "HOLD TO ZERO",
+ style = MaterialTheme.typography.labelSmall,
+ color = LevelColors.TextFaint,
+ )
+ }
+
+ Box(
+ modifier = Modifier
+ .weight(1f)
+ .fillMaxWidth()
+ .pointerInput(reading != null) {
+ detectTapGestures(
+ onLongPress = {
+ reading?.let {
+ zeroReferenceDegrees = it.tilt
+ view.performConfirmHaptic()
+ }
+ },
+ )
+ },
+ contentAlignment = Alignment.Center,
+ ) {
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ Text(
+ text = reading?.let { formatDegrees(it.tilt - zeroReferenceDegrees) } ?: "—",
+ style = MaterialTheme.typography.displayLarge,
+ color = LevelColors.TextPrimary,
+ )
+ if (zeroReferenceDegrees != 0.0) {
+ Text(
+ text = "zeroed at " + formatDegrees(zeroReferenceDegrees),
+ style = MaterialTheme.typography.labelSmall,
+ color = LevelColors.Amber,
+ textAlign = TextAlign.Center,
+ )
+ }
+ }
+ }
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(14.dp),
+ ) {
+ SecondaryValue("PITCH", reading?.pitch?.let(::formatDegrees), Modifier.weight(1f))
+ SecondaryValue("ROLL", reading?.roll?.let(::formatDegrees), Modifier.weight(1f))
+ SecondaryValue("GRADE", reading?.pitch?.let { formatGrade(OrientationMath.percentGrade(it)) }, Modifier.weight(1f))
+ }
+ }
+}
+
+@Composable
+private fun SecondaryValue(label: String, value: String?, modifier: Modifier = Modifier) {
+ Column(modifier = modifier) {
+ Text(label, style = MaterialTheme.typography.labelSmall, color = LevelColors.TextDim)
+ Text(value ?: "—", style = MaterialTheme.typography.headlineMedium, color = LevelColors.TextPrimary)
+ }
+}
+
+private fun formatGrade(grade: Double): String = when {
+ grade.isInfinite() -> "∞"
+ else -> String.format(java.util.Locale.US, "%.1f%%", grade)
+}
diff --git a/app/src/main/java/com/onthelevel/feature/level/LevelPipeline.kt b/app/src/main/java/com/onthelevel/feature/level/LevelPipeline.kt
new file mode 100644
index 0000000..2becd59
--- /dev/null
+++ b/app/src/main/java/com/onthelevel/feature/level/LevelPipeline.kt
@@ -0,0 +1,100 @@
+package com.onthelevel.feature.level
+
+import com.onthelevel.core.sensors.DisplayDeadband
+import com.onthelevel.core.sensors.EdgeCalibration
+import com.onthelevel.core.sensors.Ema
+import com.onthelevel.core.sensors.GravitySample
+import com.onthelevel.core.sensors.LevelMode
+import com.onthelevel.core.sensors.LockDetector
+import com.onthelevel.core.sensors.OrientationMath
+import com.onthelevel.core.sensors.SurfaceCalibration
+import kotlin.math.hypot
+
+/**
+ * One reading through the raw → stable pipeline (BRIEF.md values 1 and 2 of 3).
+ * Value 3 — the spring-animated display value that moves the tactile vial — is
+ * NOT yet implemented; this scaffold renders the deadbanded stable value directly.
+ */
+data class LevelReading(
+ val mode: LevelMode,
+ /** Deadbanded stable magnitude for the big readout, in degrees. */
+ val displayPrimaryDegrees: Double,
+ /** Surface: pitch. Edge: signed level deviation. Deadbanded. */
+ val secondaryADegrees: Double,
+ /** Surface: roll. Edge: plumb lean. Deadbanded. */
+ val secondaryBDegrees: Double,
+ val isLocked: Boolean,
+ val fireFeedback: Boolean,
+)
+
+/**
+ * Stateful per-collection pipeline: calibration → EMA smoothing → lock detection →
+ * display deadband. Created fresh when mode or calibration changes; the LockDetector
+ * is shared across recreations so the haptic debounce survives mode switches.
+ */
+class LevelPipeline(
+ private val mode: LevelMode,
+ private val surfaceCalibration: SurfaceCalibration,
+ private val edgeCalibration: EdgeCalibration,
+ private val lockDetector: LockDetector,
+) {
+ private val emaA = Ema(SMOOTHING_TAU_SECONDS)
+ private val emaB = Ema(SMOOTHING_TAU_SECONDS)
+ private val primaryDeadband = DisplayDeadband()
+ private val aDeadband = DisplayDeadband()
+ private val bDeadband = DisplayDeadband()
+ private var lastTimestampNanos: Long? = null
+
+ fun process(g: GravitySample): LevelReading {
+ val dtSeconds = lastTimestampNanos?.let { (g.timestampNanos - it) / 1e9 } ?: 0.0
+ lastTimestampNanos = g.timestampNanos
+ // Sensor timestamps are monotonic; using them (not wall clock) keeps the
+ // lock state machine deterministic under recorded traces.
+ val nowMillis = g.timestampNanos / 1_000_000
+
+ return when (mode) {
+ LevelMode.SURFACE -> {
+ val pitch = emaA.update(
+ surfaceCalibration.applyToPitch(OrientationMath.surfacePitchDegrees(g)),
+ dtSeconds,
+ )
+ val roll = emaB.update(
+ surfaceCalibration.applyToRoll(OrientationMath.surfaceRollDegrees(g)),
+ dtSeconds,
+ )
+ // Near level, calibrated tilt magnitude ≈ hypot of the two calibrated
+ // axis angles — keeps lock detection consistent with the displayed axes.
+ val magnitude = hypot(pitch, roll)
+ val lock = lockDetector.update(magnitude, nowMillis)
+ LevelReading(
+ mode = mode,
+ displayPrimaryDegrees = primaryDeadband.update(magnitude),
+ secondaryADegrees = aDeadband.update(pitch),
+ secondaryBDegrees = bDeadband.update(roll),
+ isLocked = lock.isLocked,
+ fireFeedback = lock.fireFeedback,
+ )
+ }
+ LevelMode.EDGE -> {
+ val level = emaA.update(
+ edgeCalibration.applyToLevel(OrientationMath.edgeLevelDegrees(g)),
+ dtSeconds,
+ )
+ val lean = emaB.update(OrientationMath.edgePlumbLeanDegrees(g), dtSeconds)
+ val lock = lockDetector.update(level, nowMillis)
+ LevelReading(
+ mode = mode,
+ displayPrimaryDegrees = primaryDeadband.update(level),
+ secondaryADegrees = aDeadband.update(level),
+ secondaryBDegrees = bDeadband.update(lean),
+ isLocked = lock.isLocked,
+ fireFeedback = lock.fireFeedback,
+ )
+ }
+ }
+ }
+
+ private companion object {
+ const val SMOOTHING_TAU_SECONDS = 0.15 // TODO(tune) against recorded traces
+ }
+}
diff --git a/app/src/main/java/com/onthelevel/feature/level/LevelScreen.kt b/app/src/main/java/com/onthelevel/feature/level/LevelScreen.kt
new file mode 100644
index 0000000..f982e85
--- /dev/null
+++ b/app/src/main/java/com/onthelevel/feature/level/LevelScreen.kt
@@ -0,0 +1,223 @@
+package com.onthelevel.feature.level
+
+import android.os.Build
+import android.view.HapticFeedbackConstants
+import android.view.View
+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.material.icons.Icons
+import androidx.compose.material.icons.outlined.Settings
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.SegmentedButton
+import androidx.compose.material3.SegmentedButtonDefaults
+import androidx.compose.material3.SingleChoiceSegmentedButtonRow
+import androidx.compose.material3.Surface
+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.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalView
+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.EdgeCalibration
+import com.onthelevel.core.sensors.LevelMode
+import com.onthelevel.core.sensors.LockDetector
+import com.onthelevel.core.sensors.SensorSource
+import com.onthelevel.core.sensors.SurfaceCalibration
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.onEach
+import java.util.Locale
+
+/**
+ * Scaffold Level screen: live numbers, manual Surface|Edge selector, lock state.
+ * TODO(feature): the tactile cross-vial visual (spring-animated display value),
+ * the brief lime lock reaction, audio cue, and the calibration flow entry.
+ * TODO(feature): in Edge mode, counter-rotate the readout so it reads upright
+ * while the portrait-locked device stands on its long edge.
+ */
+@Composable
+fun LevelScreen(container: AppContainer) {
+ KeepScreenOn()
+
+ val sensorSource = container.sensorSource
+ if (!sensorSource.isAvailable) {
+ UnsupportedDeviceMessage()
+ return
+ }
+
+ var mode by rememberSaveable { mutableStateOf(LevelMode.SURFACE) }
+ val surfaceCal by container.settings.surfaceCalibration
+ .collectAsStateWithLifecycle(initialValue = SurfaceCalibration.NONE)
+ val edgeCal by container.settings.edgeCalibration
+ .collectAsStateWithLifecycle(initialValue = EdgeCalibration.NONE)
+ val hapticsEnabled by container.settings.hapticsEnabled
+ .collectAsStateWithLifecycle(initialValue = true)
+
+ val view = LocalView.current
+ val lockDetector = remember { LockDetector() }
+ LaunchedEffect(mode) { lockDetector.reset() }
+
+ val readingFlow = remember(mode, surfaceCal, edgeCal, hapticsEnabled, view) {
+ val pipeline = LevelPipeline(mode, surfaceCal, edgeCal, lockDetector)
+ sensorSource.gravity
+ .map { pipeline.process(it) }
+ .onEach { if (it.fireFeedback && hapticsEnabled) view.performConfirmHaptic() }
+ }
+ val reading by readingFlow.collectAsStateWithLifecycle(initialValue = null)
+
+ val isLocked = reading?.isLocked == true
+ val isCalibrated = when (mode) {
+ LevelMode.SURFACE -> surfaceCal != SurfaceCalibration.NONE
+ LevelMode.EDGE -> edgeCal != EdgeCalibration.NONE
+ }
+
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(horizontal = 24.dp, vertical = 16.dp),
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Column {
+ Text("LEVEL", style = MaterialTheme.typography.labelSmall, color = LevelColors.TextDim)
+ Text(
+ text = statusLine(isCalibrated, sensorSource.kind),
+ style = MaterialTheme.typography.labelSmall,
+ color = if (isCalibrated) LevelColors.LimeLock else LevelColors.TextFaint,
+ )
+ }
+ IconButton(onClick = { /* TODO(feature): calibration & settings entry */ }) {
+ Icon(
+ Icons.Outlined.Settings,
+ contentDescription = "Calibration and settings",
+ tint = LevelColors.TextDim,
+ )
+ }
+ }
+
+ SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth().padding(top = 12.dp)) {
+ LevelMode.entries.forEachIndexed { index, entry ->
+ SegmentedButton(
+ selected = mode == entry,
+ onClick = { mode = entry },
+ shape = SegmentedButtonDefaults.itemShape(index = index, count = LevelMode.entries.size),
+ ) {
+ Text(if (entry == LevelMode.SURFACE) "Surface" else "Edge")
+ }
+ }
+ }
+
+ Box(
+ modifier = Modifier
+ .weight(1f)
+ .fillMaxWidth(),
+ contentAlignment = Alignment.Center,
+ ) {
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ Text(
+ text = reading?.let { formatDegrees(it.displayPrimaryDegrees) } ?: "—",
+ style = MaterialTheme.typography.displayLarge,
+ color = if (isLocked) LevelColors.LimeLock else LevelColors.TextPrimary,
+ )
+ // Lock state is announced with a label, never color alone (BRIEF.md).
+ Text(
+ text = when {
+ isLocked && mode == LevelMode.SURFACE -> "Surface is flat"
+ isLocked -> "Edge is level"
+ else -> ""
+ },
+ style = MaterialTheme.typography.headlineMedium,
+ color = LevelColors.LimeLockText,
+ textAlign = TextAlign.Center,
+ )
+ }
+ }
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(14.dp),
+ ) {
+ ValuePanel(
+ label = if (mode == LevelMode.SURFACE) "PITCH" else "LEVEL",
+ value = reading?.secondaryADegrees,
+ modifier = Modifier.weight(1f),
+ )
+ ValuePanel(
+ label = if (mode == LevelMode.SURFACE) "ROLL" else "LEAN",
+ value = reading?.secondaryBDegrees,
+ modifier = Modifier.weight(1f),
+ )
+ }
+ }
+}
+
+@Composable
+private fun ValuePanel(label: String, value: Double?, modifier: Modifier = Modifier) {
+ Surface(
+ modifier = modifier,
+ color = LevelColors.Panel,
+ contentColor = LevelColors.TextPrimary,
+ shape = MaterialTheme.shapes.large,
+ ) {
+ Column(modifier = Modifier.padding(16.dp)) {
+ Text(label, style = MaterialTheme.typography.labelSmall, color = LevelColors.TextDim)
+ Text(
+ text = value?.let { formatDegrees(it) } ?: "—",
+ style = MaterialTheme.typography.headlineMedium,
+ )
+ }
+ }
+}
+
+@Composable
+private fun UnsupportedDeviceMessage() {
+ Box(modifier = Modifier.fillMaxSize().padding(32.dp), contentAlignment = Alignment.Center) {
+ Text(
+ text = "This device has no usable tilt sensor, so On the Level can't take measurements here.",
+ style = MaterialTheme.typography.headlineMedium,
+ textAlign = TextAlign.Center,
+ color = LevelColors.TextDim,
+ )
+ }
+}
+
+private fun statusLine(isCalibrated: Boolean, kind: SensorSource.Kind): String {
+ val source = when (kind) {
+ SensorSource.Kind.GAME_ROTATION_VECTOR -> "fused"
+ SensorSource.Kind.GRAVITY -> "gravity"
+ SensorSource.Kind.ACCELEROMETER -> "accelerometer"
+ SensorSource.Kind.NONE -> "no sensor"
+ }
+ return (if (isCalibrated) "calibrated" else "live") + " · " + source
+}
+
+internal fun formatDegrees(value: Double): String = String.format(Locale.US, "%.1f°", value)
+
+internal fun View.performConfirmHaptic() {
+ val constant = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+ HapticFeedbackConstants.CONFIRM
+ } else {
+ HapticFeedbackConstants.VIRTUAL_KEY
+ }
+ performHapticFeedback(constant)
+}
diff --git a/app/src/main/java/com/onthelevel/feature/ruler/RulerScreen.kt b/app/src/main/java/com/onthelevel/feature/ruler/RulerScreen.kt
new file mode 100644
index 0000000..39330a6
--- /dev/null
+++ b/app/src/main/java/com/onthelevel/feature/ruler/RulerScreen.kt
@@ -0,0 +1,55 @@
+package com.onthelevel.feature.ruler
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+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.LevelColors
+
+/**
+ * Scaffold Screen Ruler: Pro preview only (BRIEF.md §Tools — calm, intentional
+ * paywall; never an interruption in the free level/angle flow).
+ * TODO(pro): guided calibration (credit-card 85.60 mm + conventional ruler),
+ * scale stored per display identity, recalibration prompt on material display change.
+ */
+@Composable
+fun RulerScreen(container: AppContainer) {
+ val entitlement by container.entitlement.entitlement.collectAsStateWithLifecycle()
+
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(32.dp),
+ verticalArrangement = Arrangement.Center,
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Text("SCREEN RULER", style = MaterialTheme.typography.labelSmall, color = LevelColors.TextDim)
+ Text(
+ text = if (entitlement.isPro) {
+ "Ruler coming in the feature build."
+ } else {
+ "The calibrated screen ruler is part of On the Level Pro."
+ },
+ style = MaterialTheme.typography.headlineMedium,
+ textAlign = TextAlign.Center,
+ modifier = Modifier.padding(top = 12.dp),
+ )
+ Text(
+ text = "For short, rough measurements only — not a substitute for a tape measure.",
+ style = MaterialTheme.typography.labelSmall,
+ color = LevelColors.TextDim,
+ textAlign = TextAlign.Center,
+ modifier = Modifier.padding(top = 16.dp),
+ )
+ }
+}
diff --git a/app/src/main/java/com/onthelevel/feature/tools/ToolsScreen.kt b/app/src/main/java/com/onthelevel/feature/tools/ToolsScreen.kt
new file mode 100644
index 0000000..d99bc01
--- /dev/null
+++ b/app/src/main/java/com/onthelevel/feature/tools/ToolsScreen.kt
@@ -0,0 +1,82 @@
+package com.onthelevel.feature.tools
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+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.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import com.onthelevel.core.design.LevelColors
+
+@Composable
+fun ToolsScreen(onOpenRuler: () -> Unit) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(24.dp),
+ verticalArrangement = Arrangement.spacedBy(14.dp),
+ ) {
+ Text("TOOLS", style = MaterialTheme.typography.labelSmall, color = LevelColors.TextDim)
+
+ ToolCard(
+ title = "Screen Ruler",
+ subtitle = "Rough on-screen measuring",
+ badge = "PRO",
+ onClick = onOpenRuler,
+ )
+ ToolCard(
+ title = "Calibration",
+ subtitle = "Two-sample level calibration — coming with the feature build",
+ onClick = { /* TODO(feature): guided calibration flow */ },
+ )
+ ToolCard(
+ title = "Units & Feedback",
+ subtitle = "Haptics, audio cue, reduced motion — coming with the feature build",
+ onClick = { /* TODO(feature): preferences UI over SettingsRepository */ },
+ )
+ }
+}
+
+@Composable
+private fun ToolCard(
+ title: String,
+ subtitle: String,
+ badge: String? = null,
+ onClick: () -> Unit,
+) {
+ Surface(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable(onClick = onClick),
+ color = LevelColors.Panel,
+ contentColor = LevelColors.TextPrimary,
+ shape = MaterialTheme.shapes.large,
+ ) {
+ Column(modifier = Modifier.padding(18.dp)) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Text(title, style = MaterialTheme.typography.headlineMedium)
+ if (badge != null) {
+ Text(
+ text = " $badge",
+ style = MaterialTheme.typography.labelSmall,
+ color = LevelColors.Amber,
+ )
+ }
+ }
+ Text(
+ text = subtitle,
+ style = MaterialTheme.typography.labelSmall,
+ color = LevelColors.TextDim,
+ modifier = Modifier.padding(top = 6.dp),
+ )
+ }
+ }
+}
diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml
new file mode 100644
index 0000000..af905a2
--- /dev/null
+++ b/app/src/main/res/drawable/ic_launcher_foreground.xml
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 0000000..b51695c
--- /dev/null
+++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml
new file mode 100644
index 0000000..d780da5
--- /dev/null
+++ b/app/src/main/res/values/colors.xml
@@ -0,0 +1,5 @@
+
+
+ #FF0A0A0B
+ #FFFFC44E
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
new file mode 100644
index 0000000..4727085
--- /dev/null
+++ b/app/src/main/res/values/strings.xml
@@ -0,0 +1,4 @@
+
+
+ On the Level
+
diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml
new file mode 100644
index 0000000..b751d39
--- /dev/null
+++ b/app/src/main/res/values/themes.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
diff --git a/app/src/test/java/com/onthelevel/core/sensors/DisplayDeadbandTest.kt b/app/src/test/java/com/onthelevel/core/sensors/DisplayDeadbandTest.kt
new file mode 100644
index 0000000..3682842
--- /dev/null
+++ b/app/src/test/java/com/onthelevel/core/sensors/DisplayDeadbandTest.kt
@@ -0,0 +1,30 @@
+package com.onthelevel.core.sensors
+
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class DisplayDeadbandTest {
+
+ @Test
+ fun `quantizes to tenths of a degree`() {
+ val band = DisplayDeadband()
+ assertEquals(0.1, band.update(0.14), 1e-9)
+ }
+
+ @Test
+ fun `noise on a boundary does not flicker the readout`() {
+ val band = DisplayDeadband()
+ val first = band.update(0.05) // boundary between 0.0 and 0.1
+ // Jitter of ±0.02° around the boundary must hold the displayed value.
+ assertEquals(first, band.update(0.06), 1e-9)
+ assertEquals(first, band.update(0.04), 1e-9)
+ assertEquals(first, band.update(0.05), 1e-9)
+ }
+
+ @Test
+ fun `a real change moves the readout`() {
+ val band = DisplayDeadband()
+ band.update(0.0)
+ assertEquals(0.2, band.update(0.2), 1e-9)
+ }
+}
diff --git a/app/src/test/java/com/onthelevel/core/sensors/EmaTest.kt b/app/src/test/java/com/onthelevel/core/sensors/EmaTest.kt
new file mode 100644
index 0000000..f6aaad7
--- /dev/null
+++ b/app/src/test/java/com/onthelevel/core/sensors/EmaTest.kt
@@ -0,0 +1,42 @@
+package com.onthelevel.core.sensors
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class EmaTest {
+
+ @Test
+ fun `first sample passes through unfiltered`() {
+ assertEquals(5.0, Ema(0.15).update(5.0, 0.0), 1e-9)
+ }
+
+ @Test
+ fun `converges toward a constant input`() {
+ val ema = Ema(0.15)
+ ema.update(0.0, 0.0)
+ var value = 0.0
+ repeat(100) { value = ema.update(10.0, 0.02) }
+ assertEquals(10.0, value, 1e-3)
+ }
+
+ @Test
+ fun `one time constant covers ~63 percent of a step`() {
+ val ema = Ema(1.0)
+ ema.update(0.0, 0.0)
+ val afterOneTau = ema.update(1.0, 1.0)
+ assertEquals(0.632, afterOneTau, 0.01)
+ }
+
+ @Test
+ fun `same elapsed time yields same smoothing regardless of sample rate`() {
+ val fast = Ema(0.5)
+ val slow = Ema(0.5)
+ fast.update(0.0, 0.0)
+ slow.update(0.0, 0.0)
+ var fastValue = 0.0
+ repeat(10) { fastValue = fast.update(1.0, 0.01) } // 100 ms in 10 steps
+ val slowValue = slow.update(1.0, 0.1) // 100 ms in 1 step
+ assertTrue(kotlin.math.abs(fastValue - slowValue) < 0.02)
+ }
+}
diff --git a/app/src/test/java/com/onthelevel/core/sensors/LockDetectorTest.kt b/app/src/test/java/com/onthelevel/core/sensors/LockDetectorTest.kt
new file mode 100644
index 0000000..c639dc6
--- /dev/null
+++ b/app/src/test/java/com/onthelevel/core/sensors/LockDetectorTest.kt
@@ -0,0 +1,73 @@
+package com.onthelevel.core.sensors
+
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class LockDetectorTest {
+
+ private fun detector() = LockDetector(
+ enterThresholdDegrees = 0.2,
+ exitThresholdDegrees = 0.35,
+ dwellMillis = 400,
+ feedbackDebounceMillis = 3_000,
+ )
+
+ @Test
+ fun `lock requires dwell time inside the enter threshold`() {
+ val d = detector()
+ assertFalse(d.update(0.1, 0).isLocked)
+ assertFalse(d.update(0.1, 200).isLocked)
+ val result = d.update(0.1, 450)
+ assertTrue(result.isLocked)
+ assertTrue(result.fireFeedback)
+ }
+
+ @Test
+ fun `leaving the threshold before dwell completes resets the timer`() {
+ val d = detector()
+ d.update(0.1, 0)
+ d.update(0.5, 200) // bounced out
+ d.update(0.1, 300) // back in — dwell restarts
+ assertFalse(d.update(0.1, 600).isLocked) // only 300ms since re-entry
+ assertTrue(d.update(0.1, 750).isLocked)
+ }
+
+ @Test
+ fun `hysteresis holds the lock between enter and exit thresholds`() {
+ val d = detector()
+ d.update(0.1, 0)
+ assertTrue(d.update(0.1, 500).isLocked)
+ // 0.3° is above enter (0.2) but below exit (0.35): still locked.
+ assertTrue(d.update(0.3, 600).isLocked)
+ // 0.4° exceeds the exit threshold: unlocked.
+ assertFalse(d.update(0.4, 700).isLocked)
+ }
+
+ @Test
+ fun `feedback fires once per lock and respects the debounce window`() {
+ val d = detector()
+ d.update(0.1, 0)
+ assertTrue(d.update(0.1, 500).fireFeedback)
+ assertFalse(d.update(0.1, 600).fireFeedback) // still locked, no re-fire
+
+ // Rock out and back in quickly: re-lock at ~1500ms is inside the 3s debounce.
+ d.update(0.5, 900)
+ d.update(0.1, 1000)
+ val relock = d.update(0.1, 1500)
+ assertTrue(relock.isLocked)
+ assertFalse(relock.fireFeedback)
+
+ // A re-lock after the debounce window fires again.
+ d.update(0.5, 2000)
+ d.update(0.1, 4000)
+ assertTrue(d.update(0.1, 4500).fireFeedback)
+ }
+
+ @Test
+ fun `negative readings lock on magnitude`() {
+ val d = detector()
+ d.update(-0.1, 0)
+ assertTrue(d.update(-0.1, 500).isLocked)
+ }
+}
diff --git a/app/src/test/java/com/onthelevel/core/sensors/OrientationMathTest.kt b/app/src/test/java/com/onthelevel/core/sensors/OrientationMathTest.kt
new file mode 100644
index 0000000..1558ef1
--- /dev/null
+++ b/app/src/test/java/com/onthelevel/core/sensors/OrientationMathTest.kt
@@ -0,0 +1,85 @@
+package com.onthelevel.core.sensors
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import kotlin.math.cos
+import kotlin.math.sin
+
+class OrientationMathTest {
+
+ private val g = 9.80665
+
+ private fun flat() = GravitySample(0.0, 0.0, g, 0)
+
+ /** Device tilted so the top edge is raised by [degrees] (rotation about the X axis). */
+ private fun pitched(degrees: Double): GravitySample {
+ val r = Math.toRadians(degrees)
+ return GravitySample(0.0, g * sin(r), g * cos(r), 0)
+ }
+
+ /** Device tilted so the right edge is raised by [degrees] (rotation about the Y axis). */
+ private fun rolled(degrees: Double): GravitySample {
+ val r = Math.toRadians(degrees)
+ return GravitySample(g * sin(r), 0.0, g * cos(r), 0)
+ }
+
+ /** Device standing on its long edge, tipped in the wall plane by [degrees]. */
+ private fun onEdge(tipDegrees: Double): GravitySample {
+ val r = Math.toRadians(tipDegrees)
+ return GravitySample(g * cos(r), g * sin(r), 0.0, 0)
+ }
+
+ @Test
+ fun `flat device reads zero everywhere in surface mode`() {
+ assertEquals(0.0, OrientationMath.surfaceTiltMagnitudeDegrees(flat()), 1e-9)
+ assertEquals(0.0, OrientationMath.surfacePitchDegrees(flat()), 1e-9)
+ assertEquals(0.0, OrientationMath.surfaceRollDegrees(flat()), 1e-9)
+ }
+
+ @Test
+ fun `pitch recovers the applied rotation with correct sign`() {
+ assertEquals(1.0, OrientationMath.surfacePitchDegrees(pitched(1.0)), 1e-9)
+ assertEquals(-2.5, OrientationMath.surfacePitchDegrees(pitched(-2.5)), 1e-9)
+ assertEquals(0.0, OrientationMath.surfaceRollDegrees(pitched(1.0)), 1e-9)
+ }
+
+ @Test
+ fun `roll recovers the applied rotation with correct sign`() {
+ assertEquals(3.0, OrientationMath.surfaceRollDegrees(rolled(3.0)), 1e-9)
+ assertEquals(0.0, OrientationMath.surfacePitchDegrees(rolled(3.0)), 1e-9)
+ }
+
+ @Test
+ fun `tilt magnitude matches single-axis rotations`() {
+ assertEquals(1.0, OrientationMath.surfaceTiltMagnitudeDegrees(pitched(1.0)), 1e-9)
+ assertEquals(4.0, OrientationMath.surfaceTiltMagnitudeDegrees(rolled(4.0)), 1e-9)
+ }
+
+ @Test
+ fun `edge mode reads zero when the long edge is horizontal`() {
+ assertEquals(0.0, OrientationMath.edgeLevelDegrees(onEdge(0.0)), 1e-9)
+ assertEquals(0.0, OrientationMath.edgePlumbLeanDegrees(onEdge(0.0)), 1e-9)
+ }
+
+ @Test
+ fun `edge mode recovers in-plane tip with correct sign`() {
+ assertEquals(1.5, OrientationMath.edgeLevelDegrees(onEdge(1.5)), 1e-9)
+ assertEquals(-2.0, OrientationMath.edgeLevelDegrees(onEdge(-2.0)), 1e-9)
+ }
+
+ @Test
+ fun `percent grade is tan-based and capped to infinity near vertical`() {
+ assertEquals(0.0, OrientationMath.percentGrade(0.0), 1e-9)
+ assertEquals(100.0, OrientationMath.percentGrade(45.0), 1e-6)
+ assertTrue(OrientationMath.percentGrade(89.6).isInfinite())
+ assertTrue(OrientationMath.percentGrade(-89.6) == Double.NEGATIVE_INFINITY)
+ }
+
+ @Test
+ fun `zero vector does not produce NaN`() {
+ val zero = GravitySample(0.0, 0.0, 0.0, 0)
+ assertEquals(0.0, OrientationMath.surfaceTiltMagnitudeDegrees(zero), 1e-9)
+ assertEquals(0.0, OrientationMath.edgeLevelDegrees(zero), 1e-9)
+ }
+}
diff --git a/app/src/test/java/com/onthelevel/core/sensors/TwoSampleCalibrationTest.kt b/app/src/test/java/com/onthelevel/core/sensors/TwoSampleCalibrationTest.kt
new file mode 100644
index 0000000..0632caa
--- /dev/null
+++ b/app/src/test/java/com/onthelevel/core/sensors/TwoSampleCalibrationTest.kt
@@ -0,0 +1,47 @@
+package com.onthelevel.core.sensors
+
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class TwoSampleCalibrationTest {
+
+ @Test
+ fun `flip cancels true tilt and isolates device bias`() {
+ // Surface truly tilted 0.5°, device bias +0.3°:
+ val reading1 = 0.5 + 0.3 // as placed
+ val reading2 = -0.5 + 0.3 // after 180° rotation about the surface normal
+ val bias = TwoSampleCalibration.deriveBiasDegrees(reading1, reading2)
+ assertEquals(0.3, bias, 1e-9)
+ // Applying the bias recovers the true tilt from the original reading:
+ assertEquals(0.5, reading1 - bias, 1e-9)
+ }
+
+ @Test
+ fun `surface calibration derives both axes independently`() {
+ val cal = TwoSampleCalibration.deriveSurface(
+ pitch1 = 0.8, roll1 = -0.1,
+ pitch2 = -0.2, roll2 = 0.5,
+ )
+ assertEquals(0.3, cal.pitchBiasDegrees, 1e-9)
+ assertEquals(0.2, cal.rollBiasDegrees, 1e-9)
+ assertEquals(0.5, cal.applyToPitch(0.8), 1e-9)
+ assertEquals(-0.3, cal.applyToRoll(-0.1), 1e-9)
+ }
+
+ @Test
+ fun `unbiased device on a level surface derives zero bias`() {
+ val cal = TwoSampleCalibration.deriveEdge(0.0, 0.0)
+ assertEquals(0.0, cal.levelBiasDegrees, 1e-9)
+ assertEquals(1.2, cal.applyToLevel(1.2), 1e-9)
+ }
+
+ @Test
+ fun `surface and edge calibrations are independent types applied per mode`() {
+ val surface = TwoSampleCalibration.deriveSurface(1.0, 1.0, 0.0, 0.0)
+ val edge = EdgeCalibration.NONE
+ // An edge reading passed through the untouched edge calibration is unchanged,
+ // regardless of surface calibration state (AUDIT.md finding 3).
+ assertEquals(0.7, edge.applyToLevel(0.7), 1e-9)
+ assertEquals(0.5, surface.pitchBiasDegrees, 1e-9)
+ }
+}
diff --git a/build.gradle.kts b/build.gradle.kts
new file mode 100644
index 0000000..020183f
--- /dev/null
+++ b/build.gradle.kts
@@ -0,0 +1,4 @@
+plugins {
+ alias(libs.plugins.android.application) apply false
+ alias(libs.plugins.kotlin.compose) apply false
+}
diff --git a/gradle.properties b/gradle.properties
new file mode 100644
index 0000000..f0a2e55
--- /dev/null
+++ b/gradle.properties
@@ -0,0 +1,4 @@
+org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
+android.useAndroidX=true
+kotlin.code.style=official
+android.nonTransitiveRClass=true
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
new file mode 100644
index 0000000..981073b
--- /dev/null
+++ b/gradle/libs.versions.toml
@@ -0,0 +1,42 @@
+[versions]
+agp = "9.2.1"
+kotlin = "2.2.10"
+coreKtx = "1.18.0"
+activityCompose = "1.10.1"
+lifecycle = "2.8.7"
+composeBom = "2025.06.01"
+navigation = "2.8.4"
+datastore = "1.1.6"
+billing = "9.1.0"
+junit = "4.13.2"
+coroutinesTest = "1.9.0"
+androidxJunit = "1.3.0"
+espresso = "3.7.0"
+
+[libraries]
+androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
+androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
+androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" }
+androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle" }
+androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" }
+
+androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
+androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
+androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
+androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
+androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
+androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
+androidx-compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" }
+
+androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigation" }
+androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
+billing-ktx = { group = "com.android.billingclient", name = "billing-ktx", version.ref = "billing" }
+
+junit = { group = "junit", name = "junit", version.ref = "junit" }
+kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutinesTest" }
+androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidxJunit" }
+androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espresso" }
+
+[plugins]
+android-application = { id = "com.android.application", version.ref = "agp" }
+kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000..8bdaf60
Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..8e61ef1
--- /dev/null
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,8 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionSha256Sum=2ab2958f2a1e51120c326cad6f385153bb11ee93b3c216c5fccebfdfbb7ec6cb
+distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip
+networkTimeout=10000
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/gradlew b/gradlew
new file mode 100755
index 0000000..ef07e01
--- /dev/null
+++ b/gradlew
@@ -0,0 +1,251 @@
+#!/bin/sh
+
+#
+# Copyright © 2015 the original authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+#
+
+##############################################################################
+#
+# Gradle start up script for POSIX generated by Gradle.
+#
+# Important for running:
+#
+# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
+# noncompliant, but you have some other compliant shell such as ksh or
+# bash, then to run this script, type that shell name before the whole
+# command line, like:
+#
+# ksh Gradle
+#
+# Busybox and similar reduced shells will NOT work, because this script
+# requires all of these POSIX shell features:
+# * functions;
+# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
+# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
+# * compound commands having a testable exit status, especially «case»;
+# * various built-in commands including «command», «set», and «ulimit».
+#
+# Important for patching:
+#
+# (2) This script targets any POSIX shell, so it avoids extensions provided
+# by Bash, Ksh, etc; in particular arrays are avoided.
+#
+# The "traditional" practice of packing multiple parameters into a
+# space-separated string is a well documented source of bugs and security
+# problems, so this is (mostly) avoided, by progressively accumulating
+# options in "$@", and eventually passing that to Java.
+#
+# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
+# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
+# see the in-line comments for details.
+#
+# There are tweaks for specific operating systems such as AIX, CygWin,
+# Darwin, MinGW, and NonStop.
+#
+# (3) This script is generated from the Groovy template
+# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+# within the Gradle project.
+#
+# You can find Gradle at https://github.com/gradle/gradle/.
+#
+##############################################################################
+
+# Attempt to set APP_HOME
+
+# Resolve links: $0 may be a link
+app_path=$0
+
+# Need this for daisy-chained symlinks.
+while
+ APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
+ [ -h "$app_path" ]
+do
+ ls=$( ls -ld "$app_path" )
+ link=${ls#*' -> '}
+ case $link in #(
+ /*) app_path=$link ;; #(
+ *) app_path=$APP_HOME$link ;;
+ esac
+done
+
+# This is normally unused
+# shellcheck disable=SC2034
+APP_BASE_NAME=${0##*/}
+# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
+APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD=maximum
+
+warn () {
+ echo "$*"
+} >&2
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+} >&2
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "$( uname )" in #(
+ CYGWIN* ) cygwin=true ;; #(
+ Darwin* ) darwin=true ;; #(
+ MSYS* | MINGW* ) msys=true ;; #(
+ NONSTOP* ) nonstop=true ;;
+esac
+
+CLASSPATH="\\\"\\\""
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD=$JAVA_HOME/jre/sh/java
+ else
+ JAVACMD=$JAVA_HOME/bin/java
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD=java
+ if ! command -v java >/dev/null 2>&1
+ then
+ die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+fi
+
+# Increase the maximum file descriptors if we can.
+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
+ case $MAX_FD in #(
+ max*)
+ # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ MAX_FD=$( ulimit -H -n ) ||
+ warn "Could not query maximum file descriptor limit"
+ esac
+ case $MAX_FD in #(
+ '' | soft) :;; #(
+ *)
+ # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ ulimit -n "$MAX_FD" ||
+ warn "Could not set maximum file descriptor limit to $MAX_FD"
+ esac
+fi
+
+# Collect all arguments for the java command, stacking in reverse order:
+# * args from the command line
+# * the main class name
+# * -classpath
+# * -D...appname settings
+# * --module-path (only if needed)
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if "$cygwin" || "$msys" ; then
+ APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
+ CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
+
+ JAVACMD=$( cygpath --unix "$JAVACMD" )
+
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ for arg do
+ if
+ case $arg in #(
+ -*) false ;; # don't mess with options #(
+ /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
+ [ -e "$t" ] ;; #(
+ *) false ;;
+ esac
+ then
+ arg=$( cygpath --path --ignore --mixed "$arg" )
+ fi
+ # Roll the args list around exactly as many times as the number of
+ # args, so each arg winds up back in the position where it started, but
+ # possibly modified.
+ #
+ # NB: a `for` loop captures its iteration list before it begins, so
+ # changing the positional parameters here affects neither the number of
+ # iterations, nor the values presented in `arg`.
+ shift # remove old arg
+ set -- "$@" "$arg" # push replacement arg
+ done
+fi
+
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Collect all arguments for the java command:
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
+# and any embedded shellness will be escaped.
+# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
+# treated as '${Hostname}' itself on the command line.
+
+set -- \
+ "-Dorg.gradle.appname=$APP_BASE_NAME" \
+ -classpath "$CLASSPATH" \
+ -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
+ "$@"
+
+# Stop when "xargs" is not available.
+if ! command -v xargs >/dev/null 2>&1
+then
+ die "xargs is not available"
+fi
+
+# Use "xargs" to parse quoted args.
+#
+# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
+#
+# In Bash we could simply go:
+#
+# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
+# set -- "${ARGS[@]}" "$@"
+#
+# but POSIX shell has neither arrays nor command substitution, so instead we
+# post-process each arg (as a line of input to sed) to backslash-escape any
+# character that might be a shell metacharacter, then use eval to reverse
+# that process (while maintaining the separation between arguments), and wrap
+# the whole thing up as a single "set" statement.
+#
+# This will of course break if any of these variables contains a newline or
+# an unmatched quote.
+#
+
+eval "set -- $(
+ printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
+ xargs -n1 |
+ sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
+ tr '\n' ' '
+ )" '"$@"'
+
+exec "$JAVACMD" "$@"
diff --git a/gradlew.bat b/gradlew.bat
new file mode 100644
index 0000000..5eed7ee
--- /dev/null
+++ b/gradlew.bat
@@ -0,0 +1,94 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+@rem SPDX-License-Identifier: Apache-2.0
+@rem
+
+@if "%DEBUG%"=="" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%"=="" set DIRNAME=.
+@rem This is normally unused
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if %ERRORLEVEL% equ 0 goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if %ERRORLEVEL% equ 0 goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+set EXIT_CODE=%ERRORLEVEL%
+if %EXIT_CODE% equ 0 set EXIT_CODE=1
+if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
+exit /b %EXIT_CODE%
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/settings.gradle.kts b/settings.gradle.kts
new file mode 100644
index 0000000..b018b1b
--- /dev/null
+++ b/settings.gradle.kts
@@ -0,0 +1,26 @@
+pluginManagement {
+ repositories {
+ google {
+ content {
+ includeGroupByRegex("com\\.android.*")
+ includeGroupByRegex("com\\.google.*")
+ includeGroupByRegex("androidx.*")
+ }
+ }
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+plugins {
+ id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0"
+}
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+
+rootProject.name = "OnTheLevel"
+include(":app")