Scaffold Android project per BRIEF.md
Single-module Kotlin/Compose app with manual DI container. Pure-Kotlin measurement math (orientation mapping, two-sample per-mode calibration, EMA smoothing, lock hysteresis, display deadband) fully unit-tested. Sensor fallback: game rotation vector -> gravity -> low-passed accelerometer. Live Level (manual Surface|Edge) and Angle (hold-to-zero) scaffolds; Ruler/Tools placeholders; stub Pro entitlement. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||
@@ -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))
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
# Add project specific ProGuard rules here.
|
||||
@@ -0,0 +1,31 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<!-- BRIEF.md: an accelerometer is the minimum viable sensor; devices without one are unsupported. -->
|
||||
<uses-feature
|
||||
android:name="android.hardware.sensor.accelerometer"
|
||||
android:required="true" />
|
||||
|
||||
<application
|
||||
android:name=".OnTheLevelApp"
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.OnTheLevel">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:screenOrientation="portrait"
|
||||
tools:ignore="LockedOrientationActivity">
|
||||
<!-- Portrait lock is a BRIEF.md v1 decision: sensor axes are remapped in code,
|
||||
and edge mode rotates its own readout rather than the Activity. -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
@@ -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<Entitlement>
|
||||
|
||||
/** 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<Entitlement> = 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.
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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<Sensor, SensorSource.Kind>? = 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<GravitySample> = 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<Double, Double, Double>? = 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
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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<GravitySample>
|
||||
|
||||
enum class Kind { GAME_ROTATION_VECTOR, GRAVITY, ACCELEROMETER, NONE }
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
@@ -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<Preferences> 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<SurfaceCalibration> = store.data.map { prefs ->
|
||||
SurfaceCalibration(
|
||||
pitchBiasDegrees = prefs[Keys.SURFACE_PITCH_BIAS] ?: 0.0,
|
||||
rollBiasDegrees = prefs[Keys.SURFACE_ROLL_BIAS] ?: 0.0,
|
||||
)
|
||||
}
|
||||
|
||||
val edgeCalibration: Flow<EdgeCalibration> = store.data.map { prefs ->
|
||||
EdgeCalibration(levelBiasDegrees = prefs[Keys.EDGE_LEVEL_BIAS] ?: 0.0)
|
||||
}
|
||||
|
||||
val hapticsEnabled: Flow<Boolean> = store.data.map { it[Keys.HAPTICS_ENABLED] ?: true }
|
||||
val audioCueEnabled: Flow<Boolean> = 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<Boolean> = 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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Placeholder launcher art: a cross-vial hairline with the amber bubble at center. -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:pathData="M30,54 L78,54"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="1.5" />
|
||||
<path
|
||||
android:pathData="M54,30 L54,78"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="1.5" />
|
||||
<path
|
||||
android:pathData="M54,54 m-16,0 a16,16 0 1,1 32,0 a16,16 0 1,1 -32,0"
|
||||
android:strokeColor="#8CCBEF5C"
|
||||
android:strokeWidth="1.5" />
|
||||
<path
|
||||
android:pathData="M54,54 m-9,0 a9,9 0 1,1 18,0 a9,9 0 1,1 -18,0"
|
||||
android:fillColor="#FFC44E" />
|
||||
</vector>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/graphite" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="graphite">#FF0A0A0B</color>
|
||||
<color name="amber">#FFFFC44E</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">On the Level</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Dark-only instrument look; Compose owns all real theming (core/design). -->
|
||||
<style name="Theme.OnTheLevel" parent="android:Theme.Material.NoActionBar">
|
||||
<item name="android:windowBackground">@color/graphite</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
plugins {
|
||||
alias(libs.plugins.android.application) apply false
|
||||
alias(libs.plugins.kotlin.compose) apply false
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||
android.useAndroidX=true
|
||||
kotlin.code.style=official
|
||||
android.nonTransitiveRClass=true
|
||||
@@ -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" }
|
||||
Vendored
BIN
Binary file not shown.
+8
@@ -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
|
||||
@@ -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" "$@"
|
||||
Vendored
+94
@@ -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
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user