> ## Documentation Index
> Fetch the complete documentation index at: https://developer.neurofit.app/llms.txt
> Use this file to discover all available pages before exploring further.

# API reference (Android)

> Every public type in the com.neurofit.vitals Kotlin package, version 1.0.0, as declared in the SDK sources.

Package `com.neurofit.vitals`, Maven `com.neurofit:vitals-sdk:1.0.0`, `minSdk 28`, `compileSdk 36`, JVM 17,
Kotlin compiler 2.2.20 with `apiVersion` and `languageVersion` 2.0, `explicitApi()`. The engine is compiled into
the AAR; nothing in the public API exposes an engine type (the `ppgcore.*` classes inside the AAR are not a
supported API and may be renamed by your release build). Engine version 1.3.0.

Dependencies declared `api` (your app compiles against them without declaring them): `androidx.camera:camera-view:1.5.0`,
`androidx.activity:activity-ktx:1.11.0`, `androidx.lifecycle:lifecycle-runtime-ktx:2.9.4`,
`org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2`. Declared `implementation` (runtime only):
`androidx.camera:camera-core`, `camera-camera2` and `camera-lifecycle`, all 1.5.0.

Java callers: every `VitalsSDK` member is `@JvmStatic` (`VitalsSDK.activate(context, key)`; the
`VitalsSDK.INSTANCE.activate(...)` form works too), `createSession`, `start` and `cancelReading` carry
`@JvmOverloads`, `VitalsConfig` has a `Builder`, and `VitalsListener` replaces the flows. `awaitResult` and
`getResult` are Kotlin-only.

This page is hand-maintained from the SDK sources and updated with every SDK change; the
[iOS page](/api-reference-ios) documents the mirrored API.

## VitalsSDK

```kotlin theme={null}
public object VitalsSDK {
  public const val sdkVersion: String = "1.0.0"
  public const val engineVersion: String   // "1.3.0" (PpgCoreVersion.STRING)
  public const val buildDate: String = "2026-09-30"   // release scripts stamp it

  /** The status from the last successful activate, or null. */
  @JvmStatic public val licenseStatus: LicenseStatus?

  /** Debug logging (tag "NeurofitVitals"). Off by default. */
  @JvmStatic public var loggingEnabled: Boolean

  /** Verifies the key offline against the public key compiled into the SDK and remembers the result.
   *  Throws VitalsException.LicenseInvalid, LicenseUpdatesExpired, LicenseNotValidForApp. */
  @JvmStatic @Throws(VitalsException::class)
  public fun activate(context: Context, licenseKey: String): LicenseStatus

  /** Rear camera, torch and 30 fps support from the camera characteristics. Does not open the camera. */
  @JvmStatic public fun deviceSupport(context: Context): DeviceSupport

  @JvmStatic public fun hasCameraPermission(context: Context): Boolean

  /** Requests android.permission.CAMERA through the activity's ActivityResultRegistry. Main thread. */
  @JvmStatic public fun requestCameraPermission(activity: ComponentActivity, onResult: (Boolean) -> Unit)

  /** Throws VitalsException.NotActivated, UnsupportedDevice or InternalError("invalid configuration: ..."). */
  @JvmStatic @JvmOverloads @Throws(VitalsException::class)
  public fun createSession(context: Context, config: VitalsConfig = VitalsConfig()): VitalsSession
}
```

Notes:

* `activate` compares the key's `apps` with the application id (`context.packageName`). When the host app is
  debuggable (`ApplicationInfo.FLAG_DEBUGGABLE`, a runtime check) a key that does not list the id is accepted with
  a note in `LicenseStatus.warnings`; a release build rejects it. See [License keys](/license-keys#debug-builds).
* `deviceSupport` reads Camera2 characteristics only. A rear camera whose AE target fps ranges do not include 30
  is unsupported; when the ranges cannot be read the device counts as supported and the runtime frame-rate gate
  decides. Whether the camera offers manual exposure controls does not affect support.
* `requestCameraPermission`: when the permission is already granted, `onResult(true)` is called synchronously and
  nothing is launched. Otherwise the SDK registers a launcher under its own key, launches the system prompt,
  delivers `onResult` on the main thread and unregisters. The registration lives on that activity instance: if the
  activity is recreated while the dialog is showing (rotation, split-screen resize), `onResult` is never called,
  so also check `hasCameraPermission` in `onResume` or lock the orientation of the screen that asks.
* `createSession` returns a session bound to the application context. `VitalsSession` has no public constructor.
* `loggingEnabled` logs state transitions and capture decisions only: never metric values, frames or license
  contents.

## LicenseStatus and LicenseType

```kotlin theme={null}
public data class LicenseStatus(
  public val licensee: String,
  public val apps: List<String>,        // application ids and bundle identifiers the key covers
  public val features: List<String>,    // ["vitals"] in 1.0.0
  public val updatesUntil: String,      // "YYYY-MM-DD": last SDK build date this key accepts
  public val type: LicenseType,
  public val warnings: List<String>,    // non-fatal notes, e.g. the relaxed app-id check in debuggable builds
)

public enum class LicenseType { BINARY, SOURCE }
```

## DeviceSupport

Exactly five fields.

```kotlin theme={null}
public data class DeviceSupport(
  public val isSupported: Boolean,
  public val hasCamera: Boolean,        // PackageManager.FEATURE_CAMERA (a rear camera)
  public val hasTorch: Boolean,         // PackageManager.FEATURE_CAMERA_FLASH; required
  public val fpsTier: Int?,             // 30 on a supported device, null otherwise
  public val reason: String?,           // why isSupported is false; null when supported
)
```

`reason` is one of: `"no rear camera"`, `"no torch (camera flash)"`, `"camera does not offer a 30 fps range"`.

## VitalsConfig

```kotlin theme={null}
public data class VitalsConfig @JvmOverloads public constructor(
  public val readingDuration: Double = 60.0,          // seconds; the validated duration
  public val autoRestartAfterCancel: Boolean = true,  // after a cancel: Positioning (true) or Idle (false)
  public val motionBreathing: Boolean = true,         // accelerometer as a breathing-rate channel
  public val advanced: AdvancedConfig = AdvancedConfig(),
) {
  /** Java-friendly builder; every setter is optional. */
  public class Builder {
    public fun readingDuration(seconds: Double): Builder
    public fun autoRestartAfterCancel(enabled: Boolean): Builder
    public fun motionBreathing(enabled: Boolean): Builder
    public fun advanced(advanced: AdvancedConfig): Builder
    public fun build(): VitalsConfig
  }
}
```

### AdvancedConfig

Capture and gating parameters with the Android production defaults. Changing them is unvalidated. The
[Reading lifecycle](/reading-lifecycle#production-defaults) page lists both platforms side by side.

```kotlin theme={null}
public data class AdvancedConfig(
  public val minAmplitude: Double = 0.0,              // 0 = not enforced on Android
  public val contactEnterFrac: Double = 0.5,
  public val contactExitFrac: Double = 0.4,
  public val startWindowSeconds: Double = 4.0,
  public val startRequiredFraction: Double = 0.75,
  public val startQualityFloor: Double = 0.5,
  public val requireExposureConverged: Boolean = true,
  public val startMaxWaitSeconds: Double = 15.0,
  public val cancelWindowSeconds: Double = 12.0,
  public val cancelMinSamples: Int = 8,
  public val cancelBadFraction: Double = 0.6,
  public val liveSqCancelConsecutive: Int = 100,      // observe only
  public val contactLossPreviews: Int = 3,
  public val liveDiscardGateSeconds: Int = 35,
  public val liveDiscardGateThreshold: Double = 0.5,
  public val exposureTunerEnabled: Boolean = true,
  public val exposureTargetDc: Double = 150.0,
  public val exposureDcMin: Double = 120.0,
  public val exposureDcMax: Double = 210.0,
  public val exposureTuneFramesPerStep: Int = 15,
  public val exposureTuneMaxSteps: Int = 8,
  public val exposureTuneGamma: Double = 1.0,
  public val exposureTunerMaxCancels: Int = 2,
  public val consistencyGate: Boolean = true,
  public val mergedIntervalFix: Boolean = true,
  public val androidEstimator: Boolean = true,
  public val fps: Int = 30,                           // capture frame rate; Android runs one fixed tier
)
```

### Configuration validation

`createSession` validates the configuration once and throws `VitalsException.InternalError` with `detail`
`"invalid configuration: <reason>"` for a value the capture layer cannot run with:

| Rule | Reason text |
| - | - |
| `readingDuration`, `minAmplitude`, `contactEnterFrac`, `contactExitFrac`, `startWindowSeconds`, `startRequiredFraction`, `startQualityFloor`, `startMaxWaitSeconds`, `cancelWindowSeconds`, `cancelBadFraction`, `liveDiscardGateThreshold`, `exposureTargetDc`, `exposureDcMin`, `exposureDcMax`, `exposureTuneGamma` must be finite | `<name> must be finite, got <value>` |
| `readingDuration` in (0, 3600] | `readingDuration must be in (0, 3600.0] seconds, got <value>` |
| `startWindowSeconds`, `startMaxWaitSeconds`, `cancelWindowSeconds`, `liveDiscardGateSeconds` in \[0, 3600] | `<name> must be in [0, 3600.0] seconds, got <value>` |
| `fps` in 1..240 | `fps must be in 1..240, got <value>` |

## VitalsSession

One session runs one reading. Create a new session for each reading screen through `VitalsSDK.createSession`.

```kotlin theme={null}
public class VitalsSession : AutoCloseable {
  /** The configuration the session was created with. */
  public val config: VitalsConfig

  /** The current state; emits on change. Updated on the SDK's engine thread. */
  public val state: StateFlow<VitalsSessionState>

  /** Every event, in order, emitted on the SDK's engine thread. Subscribe before start(); no replay.
   *  Buffer of 64 events per collector, DROP_OLDEST on overflow. */
  public val events: SharedFlow<VitalsEvent>

  /** Java-friendly callback alternative to events; callbacks run on executor. Pass null to remove. */
  public fun setListener(listener: VitalsListener?, executor: Executor)

  /** Binds the camera to owner, turns the torch on and enters Positioning. Main thread. */
  @JvmOverloads @Throws(VitalsException::class)
  public fun start(owner: LifecycleOwner, previewView: PreviewView? = null)

  /** Cancels the current attempt; the session returns to Positioning (autoRestartAfterCancel) or Idle.
   *  No-op unless measuring. Any thread. */
  @JvmOverloads
  public fun cancelReading(reason: String = "host")

  /** Tears the capture down: the SDK's own CameraX use cases only, torch off, accelerometer unregistered.
   *  Idempotent. Any thread. */
  public fun stop()
  override fun close(): Unit   // = stop()

  /** Suspends until Completed and returns the result; throws the VitalsException on Failed or a stop. */
  public suspend fun awaitResult(): VitalsResult

  /** Callback form of awaitResult for Kotlin code outside a coroutine. Kotlin-only. */
  public fun getResult(callback: (Result<VitalsResult>) -> Unit)
}

public fun interface VitalsListener {
  public fun onEvent(event: VitalsEvent)
}
```

`start(owner, previewView)` checks its preconditions and throws, in this order:

| Exception | When |
| - | - |
| `InternalError("start() must be called on the main thread")` | Called off the main looper. Nothing is touched. |
| `NotActivated` | `VitalsSDK.activate` has not succeeded. |
| `CameraDenied` | `android.permission.CAMERA` is not granted. |
| `UnsupportedDevice(reason)` | `VitalsSDK.deviceSupport(context).isSupported` is false. |
| `InternalError("The session was stopped; create a new session")` | `stop()` was called. |
| `InternalError("The session has finished; create a new session")` | The state is `Completed` or `Failed`. |
| `AlreadyRunning` | The session is running. |

A failed `start()` does not consume the session. `start()` does **not** throw `CameraUnavailable`: the camera opens
asynchronously, and a camera that cannot be opened or bound fails the session with `Failed(CameraUnavailable)`
through `state` and `events` (`awaitResult` throws it). The state is `Positioning` as soon as `start()` returns.

Other behaviours:

* **Measuring ticks.** While measuring, `state.value` is set to `Measuring(elapsed)` about once per second
  without a `VitalsEvent.State`; the `Preview` event carries the same `elapsed`.
* **Cancel without auto-restart.** With `autoRestartAfterCancel = false` a cancel emits `Cancelled`, then
  `State(Idle)`, unbinds the camera (torch off) and stops the accelerometer; `start(owner, previewView)` may be
  called again on the same session. The owner stays observed.
* **Lifecycle owner.** The session observes `owner`. `ON_STOP` while positioning or measuring fails the session
  with `Interrupted("The app moved to the background")`; while finalizing the result is kept. `ON_DESTROY` calls
  `stop()`. Lock the reading screen's orientation so a configuration change cannot destroy the owner mid-reading.
* **stop().** Before a terminal state, `stop()` sets `state` to `Idle` and fails a pending `awaitResult` /
  `getResult` with `Interrupted("session stopped before a result was produced")`; a terminal state is kept.
  `stop()` clears the Camera2 interop options it set (AE/AF off, manual exposure, AWB lock, tonemap) before
  unbinding, so the host's next use of the rear camera starts from CameraX defaults. It never calls `unbindAll()`.
* `getResult` delivers on the listener executor when one is set, otherwise on the main thread. `kotlin.Result`
  is a value class, so Java callers use `VitalsEvent.Completed` / `Failed` through `setListener` instead.
* `events` emits with `tryEmit`: a collector more than 64 events behind loses the oldest `Heartbeat` / `Preview`
  events, never the newest ones (`Completed`, `Failed`, `Cancelled` always land). A listener executor that
  rejects work (shut down) drops that callback rather than failing the engine thread.

## VitalsSessionState

```kotlin theme={null}
public sealed interface VitalsSessionState {
  public data object Idle : VitalsSessionState            // created, stopped, or idle after a cancel
  public data object Positioning : VitalsSessionState     // camera and torch on; waiting for a steady fingertip
  public data class Measuring(public val elapsed: Double) : VitalsSessionState   // seconds since the attempt started
  public data object Finalizing : VitalsSessionState
  public data class Completed(public val result: VitalsResult) : VitalsSessionState
  public data class Failed(public val error: VitalsException) : VitalsSessionState
}
```

## VitalsEvent

```kotlin theme={null}
public sealed interface VitalsEvent {
  public data class State(public val state: VitalsSessionState) : VitalsEvent          // every transition
  public data class Guidance(public val guidance: com.neurofit.vitals.Guidance,
                             public val startProgress: Double) : VitalsEvent            // ~1 Hz while positioning
  public data class Preview(public val preview: LivePreview) : VitalsEvent              // ~1 Hz while measuring
  public data class Heartbeat(public val ibiMs: Double?) : VitalsEvent                  // one per detected beat
  public data object Started : VitalsEvent                                              // the start gate opened
  public data class Cancelled(public val reason: CancelReason, public val context: CancelContext) : VitalsEvent
  public data class ReconfiguringCamera(public val lowPowerMode: Boolean) : VitalsEvent // not emitted on Android in 1.0.0
  public data class RecoverableError(public val error: VitalsRecoverableError) : VitalsEvent
  public data object MeasurementComplete : VitalsEvent                                  // duration reached; result follows
  public data class Completed(public val result: VitalsResult) : VitalsEvent
  public data class Failed(public val error: VitalsException) : VitalsEvent
}
```

`Completed` and `Failed` arrive twice: as their own event and inside the following `State(...)` event.

## Guidance

```kotlin theme={null}
public enum class Guidance {
  NO_CONTACT,     // no fingertip detected over the camera
  LOW_QUALITY,    // contact, signal not yet stable
  WEAK_SIGNAL,    // contact, pulse amplitude below minAmplitude (never with the default 0.0)
  COMPLIANT,      // eligible; the start gate is filling
  FRAME_DROP,     // camera below its frame-rate floor (24 fps)
}
```

Evaluation order per preview: `NO_CONTACT`, then `FRAME_DROP`, then `WEAK_SIGNAL`, then `LOW_QUALITY`, else
`COMPLIANT`. The SDK ships no strings; see
[Reading lifecycle](/reading-lifecycle#guidance-values-and-suggested-copy) for the NEUROFIT app's copy.

## LivePreview

```kotlin theme={null}
public data class LivePreview(
  public val elapsed: Double,               // seconds since the attempt started
  public val progress: Double,              // elapsed / readingDuration, clamped to 0..1
  public val hrBpm: Double?,
  public val rmssdMs: Double?,              // provisional HRV (RMSSD), from about 20 s; null before
  public val signalLevel: SignalLevel,
  public val breathingRate: Double?,        // breaths per minute; null until the estimate is ready (about 30 s)
  public val abortRisk: AbortRisk,
)

public enum class SignalLevel { STRONG, GOOD, FAIR, WEAK }   // quality >= 0.80 / >= 0.65 / >= 0.50 / else
public enum class AbortRisk { NONE, WARN, IMMINENT }         // distance to the no-low-discard-estimate cancel
```

`rmssdMs` is sticky: once shown it keeps the last good value until the attempt ends. `abortRisk` is `WARN` from
preview 21 of the 35 s discard deadline (60%, rounded up), `IMMINENT` from preview 30 (the last 5 s), and `NONE` as
soon as a live estimate clears the gate.

## CancelReason and CancelContext

```kotlin theme={null}
public sealed interface CancelReason {
  public data object ContactLost : CancelReason            // no contact for contactLossPreviews previews
  public data object LowQuality : CancelReason             // cancel gate: too many failed quality checks
  public data object LowLiveSignalQuality : CancelReason   // liveSqCancelConsecutive withheld estimates (observe only)
  public data object NoLowDiscardEstimate : CancelReason   // no estimate below the threshold by the deadline
  public data class Host(public val reason: String) : CancelReason   // cancelReading(reason)
}

public class CancelContext(
  public val elapsedSeconds: Double?,
  public val qualityScore: Double?,
  public val effectiveFps: Int,
  public val deviceModel: String,          // "Samsung SM-A155F", "Google Pixel 8" (manufacturer + model)
  public val redDc: Double?,               // red mean of the last frame
  public val exposureIso: Double?,         // the tuner's manual ISO; null on the auto-exposure path
  public val exposureTuneSteps: Int?,      // exposure steps taken while positioning; null when the tuner is off
  public val exposureInBand: Boolean?,     // the tuner has the red mean inside its band
) {
  public fun toMap(): Map<String, Any>
  // equals, hashCode and toString cover every field and the wire reason
}
```

`CancelContext` is a plain class, not a data class (no `copy()` or `componentN`): the cancel reason's wire form
travels inside it for `toMap()` but is not part of the eight-field public shape. `toMap()` always contains these
ten keys, in this order: `reason` (`"contact_lost"`, `"low_quality"`, `"low_live_signal_quality"`,
`"no_low_discard_estimate"`, or the host's string), `quality_score`, `elapsed_s`, `engine` (`"neurofit_vitals"`),
`refine_mode` (a per-platform engine constant), `effective_fps`, `device_model`, `red_dc`, `exposure_iso`,
`exposure_tune_steps`. Absent numbers are `-1.0` (`-1` for `exposure_tune_steps`). `exposure_in_band` is added
only when `exposureInBand` is known.

## VitalsRecoverableError

```kotlin theme={null}
public sealed interface VitalsRecoverableError {
  public data class FrameRateUnsustainable(public val fps: Double) : VitalsRecoverableError  // measured fps, 1 decimal
  public data object CameraStalled : VitalsRecoverableError
}
```

The SDK recovers from the first recoverable error in a camera session (the attempt is abandoned, the session
re-arms in `Positioning`, a stall also rebinds the camera) and fails on the second, whatever its kind:
`FrameRateUnsustainable` fails with `UnsupportedDevice("camera cannot sustain 30 fps (measured <fps> fps)")`,
`CameraStalled` with `CameraUnavailable`.

## VitalsException

A sealed class whose subclasses are ordinary classes (each throw carries its own stack trace), so match them with
`is`, never with `==`.

```kotlin theme={null}
public sealed class VitalsException(message: String) : Exception(message) {
  public class NotActivated : VitalsException                 // "VitalsSDK.activate(context, licenseKey) has not succeeded"
  public class LicenseInvalid : VitalsException               // malformed key or bad signature
  public class LicenseNotValidForApp(public val appId: String) : VitalsException
  public class LicenseUpdatesExpired(public val updatesUntil: String) : VitalsException
  public class CameraDenied : VitalsException                 // android.permission.CAMERA not granted
  public class CameraRestricted : VitalsException             // not raised on Android in 1.0.0
  public class CameraUnavailable : VitalsException            // the camera could not be opened or bound
  public class UnsupportedDevice(public val reason: String) : VitalsException
  public class Interrupted(public val reason: String) : VitalsException   // backgrounded, camera lost, stopped
  public class NoHeartRate : VitalsException                  // the reading finished without a heart rate
  public class AlreadyRunning : VitalsException
  public class InternalError(public val detail: String) : VitalsException
}
```

`message` is one English sentence per subclass with the associated value (for example `"Reading interrupted:
The app moved to the background"`, `"Internal error: invalid configuration: readingDuration must be in (0,
3600.0] seconds, got NaN"`).

## VitalsResult

```kotlin theme={null}
public data class VitalsResult(
  public val id: String,                         // UUID string
  public val capturedAt: Long,                   // epoch milliseconds when the reading finished
  public val durationSeconds: Double,
  public val heartRateBpm: Double?,
  public val rmssdMs: Double?,                   // HRV (RMSSD), ms
  public val sdnnMs: Double?,
  public val baevskyStressIndex: Double?,
  public val sd2sd1: Double?,                    // Poincaré SD2:SD1
  public val breathingRateBrpm: Double?,         // returned at any confidence
  public val breathingRateConfidence: Double?,   // null exactly when breathingRateBrpm is null; 0..1
  public val confidence: Double,                 // HRV confidence, else heart-rate confidence, else 0
  public val quality: SignalQuality,
  public val flags: List<String>,
  public val diagnostics: VitalsDiagnostics,
  public val engineVersion: String,              // "1.3.0"
  public val sdkVersion: String,                 // "1.0.0"
) {
  /** camelCase keys; capturedAt as ISO 8601; quality as its wireName; diagnostics as its snake_case map. */
  public fun toMap(): Map<String, Any?>
}

public enum class SignalQuality(public val value: Int) {
  CLEAN(3), USABLE(2), WITHHELD(1);
  public val wireName: String   // "clean" / "usable" / "withheld"
}
```

`toMap()` is the shape the wrappers deliver. Field meanings and units are on the [Metrics](/metrics) page.

## VitalsDiagnostics

One camelCase property per key; `toMap()` serialises to the snake\_case keys shown. Nullable properties are
absent from the map (not `-1`) when the step that produces them did not run.

```kotlin theme={null}
public data class VitalsDiagnostics(
  // Provenance
  public val engine: String = ENGINE,            // engine: "neurofit_vitals"
  public val engineVersion: String,              // engine_version
  public val hrSource: String,                   // hr_source
  public val refineMode: String,                 // refine_mode: a per-platform engine constant. Opaque.
  public val effectiveFps: Int,                  // effective_fps
  // Quality
  public val signalQuality: Int,                 // signal_quality
  public val qualityPassedFraction: Double,      // quality_passed_fraction
  public val discardRatio: Double,               // discard_ratio
  public val saturationFrac: Double,             // saturation_frac
  public val measuredFps: Double,                // measured_fps
  public val frameIntervalCv: Double,            // frame_interval_cv
  public val frameDropComplianceErrorCount: Int, // frame_drop_compliance_error_count
  public val amplitude: Double,                  // amplitude
  public val spectralConcentration: Double,      // spectral_concentration
  public val snrDb: Double,                      // snr_db
  public val skewness: Double,                   // skewness
  // Capture hardware
  public val torchActive: Boolean,               // torch_active
  public val torchLevel: Double,                 // torch_level (1.0 on, 0.0 off, -1.0 unknown)
  public val exposureIso: Double,                // exposure_iso (-1.0 when auto exposure owns it)
  public val exposureDurationMs: Double,         // exposure_duration_ms
  public val exposureLocked: Boolean,            // exposure_locked (the manual capture config was applied)
  // Quality trajectory
  public val liveSqAt15s: Double,                // live_sq_at_15s
  public val liveSqAt30s: Double,                // live_sq_at_30s
  public val earlyMeanQualityScore: Double,      // early_mean_quality_score
  public val earlyMinQualityScore: Double,       // early_min_quality_score
  public val earlyMeanAmplitude: Double,         // early_mean_amplitude
  public val liveSqMaxWithheldRun: Int,          // live_sq_max_withheld_run
  public val liveSqWithheldEstimates: Int,       // live_sq_withheld_estimates
  public val liveSqTotalEstimates: Int,          // live_sq_total_estimates
  public val liveSqWithheldSeq: String,          // live_sq_withheld_seq
  public val earlyMeanDiscardRatio: Double,      // early_mean_discard_ratio
  public val earlyMaxDiscardRatio: Double,       // early_max_discard_ratio
  // Configuration mirrors
  public val gateEnabled: Boolean,               // gate_enabled
  public val gateMergedFixEnabled: Boolean,      // gate_merged_fix_enabled
  public val estAndroidEstimatorEnabled: Boolean,// est_android_estimator_enabled
  // Breathing
  public val motionSamples: Int,                 // motion_samples
  public val rrMotionBrpm: Double? = null,       // rr_motion_brpm
  public val rrMotionConc: Double? = null,       // rr_motion_conc
  // Engine (present only when the consistency gate ran)
  public val gateRejected: Boolean? = null,      // gate_rejected
  public val gateVeto: Boolean? = null,          // gate_veto
  public val gateNPairs: Int? = null,            // gate_n_pairs
  public val gateNMerged: Int? = null,           // gate_n_merged
  public val gateDiscardRaw: Double? = null,     // gate_discard_raw
  public val gateMaxJump: Double? = null,        // gate_max_jump
  public val gateHfShare: Double? = null,        // gate_hf_share
  public val gateCompress: Double? = null,       // gate_compress
  public val gateRPs: Double? = null,            // gate_r_ps
  public val estColOverProd: Double? = null,     // est_col_over_prod
  public val estRmssdParabolic: Double? = null,  // est_rmssd_parabolic
  public val estRmssdFixed: Double? = null,      // est_rmssd_fixed
  public val estGmRatio: Double? = null,         // est_gm_ratio
  public val estGmApplied: Boolean? = null,      // est_gm_applied
  public val estArm2Fired: Boolean? = null,      // est_arm2_fired
) {
  /** The full snake_case map, identical to the keys on the Diagnostics page. */
  public fun toMap(): Map<String, Any>

  public companion object {
    /** The engine value every NEUROFIT Vitals reading carries. */
    public const val ENGINE: String = "neurofit_vitals"
  }
}
```

See [Diagnostics](/diagnostics) for every key's meaning. The iOS record differs in a few keys (the iOS-only
camera-format keys such as `red_dc_mean` and `active_format_width` do not exist here; `est_android_estimator_enabled`
and `est_gm_ratio` exist only here).

## VitalsChange

Provisional helper for pre/post comparisons; see
[Outcomes best practices](/outcomes-best-practices#is-a-change-real).

```kotlin theme={null}
public object VitalsChange {
  public const val HEART_RATE_THRESHOLD_BPM: Double = 5.0
  public const val RMSSD_THRESHOLD_MS: Double = 10.0
  public const val BREATHING_RATE_THRESHOLD_BRPM: Double = 2.0

  @JvmStatic
  public fun compare(baseline: VitalsResult, followUp: VitalsResult): ChangeAssessment
}

public data class ChangeAssessment(
  public val heartRateBpm: MetricChange?,        // threshold 5 bpm
  public val rmssdMs: MetricChange?,             // threshold 10 ms
  public val breathingRateBrpm: MetricChange?,   // threshold 2 brpm
  public val sdnnMs: MetricChange?,              // no threshold; beyondTypicalVariation is null
  public val baevskyStressIndex: MetricChange?,  // no threshold
  public val sd2sd1: MetricChange?,              // no threshold
)

public data class MetricChange(
  public val baseline: Double,
  public val followUp: Double,
  public val delta: Double,                      // followUp - baseline
  public val beyondTypicalVariation: Boolean?,   // |delta| > threshold; null when the metric has no threshold
)
```

A metric's entry is `null` when either reading lacks the metric.

## Threading

* `VitalsSDK.activate`, `licenseStatus`, `loggingEnabled`, `deviceSupport`, `hasCameraPermission`,
  `createSession`, `VitalsSession.cancelReading`, `stop`, `close`, `setListener`, `awaitResult` and `getResult`
  are safe from any thread.
* `VitalsSDK.requestCameraPermission` and `VitalsSession.start` must be called on the main thread; `start()` throws
  `InternalError` otherwise.
* `state` and `events` are updated and emitted on the SDK's single engine thread. Collect them on the dispatcher
  you need (for example `lifecycleScope.launch { session.events.collect { ... } }`, which moves them to the main
  thread). `setListener` callbacks run on the executor you pass.
* Camera frames are extracted on a CameraX analysis thread, the accelerometer is read on its own handler thread,
  the engine's finalize runs on its own executor and the live HRV (RMSSD) preview on another. Nothing blocks the
  main thread.

## ProGuard / R8

The AAR ships `consumer-rules.pro`. It keeps the public API in the top-level package only, with the single
wildcard (`com.neurofit.vitals.*`, not `.**`): the internals under `com.neurofit.vitals.capture`, `.license`,
`.reading` and the engine under `ppgcore.*` may be shrunk and obfuscated. If your build ignores consumer rules,
add:

```
-keep public class com.neurofit.vitals.* { public *; protected *; }
-keep public interface com.neurofit.vitals.* { *; }
-keepclassmembers enum com.neurofit.vitals.* {
    public static **[] values();
    public static ** valueOf(java.lang.String);
}
-keep class kotlin.Metadata { *; }
-keepattributes Signature, InnerClasses, EnclosingMethod, Exceptions, *Annotation*, SourceFile, LineNumberTable
```

## Logging

`VitalsSDK.loggingEnabled` (default `false`) turns on `android.util.Log` messages with tag `NeurofitVitals`
(debug and warning levels): state transitions and capture decisions only. No metric values, frames or license
contents are ever logged.

## Manifest

The AAR's manifest declares `android.permission.CAMERA` and marks `android.hardware.camera` and
`android.hardware.camera.flash` as `required="false"`, so a host app stays installable on devices without them;
`VitalsSDK.deviceSupport(context)` reports them at runtime. No other permission is requested; the SDK does not use
`INTERNET`.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.