> ## 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.

# Reading lifecycle

> The session state machine, every event the SDK emits, the guidance values with suggested copy, and the production defaults.

A `VitalsSession` runs one reading: it turns the camera and torch on, waits for a fingertip, measures for 60
seconds, computes the result and turns the camera off. The state machine is the same on iOS and Android (the
platform differences are called out below), and the same events are marshalled to the React Native, Flutter and
Capacitor wrappers. The result carries heart rate, HRV (RMSSD), breathing rate and related measures; see
[Metrics](/metrics).

## States

```
idle -> positioning -> measuring -> finalizing -> completed
                 ^           |
                 +-----------+   cancelled(reason): back to positioning when autoRestartAfterCancel (default),
                                 otherwise idle (camera off; start() works again)
failed(error): terminal
```

| State | Meaning | What to show |
| - | - | - |
| `idle` | Session created, or idle again after a cancel without auto-restart or after `stop()`. Camera off. | Your intro screen. |
| `positioning` | Camera and torch on, waiting for a good fingertip signal. | The current `Guidance` and, once compliant, the start progress. |
| `measuring(elapsed)` | Reading in progress. `elapsed` is seconds since the attempt started. | Live values, a progress ring, "hold still". |
| `finalizing` | The duration was captured; the result is being computed off the main thread. The torch stays on until the result is built. | A short "finishing" state, usually 1 to 3 seconds. |
| `completed(result)` | Done. The camera and torch are off. | Your result screen. |
| `failed(error)` | A non-recoverable error. The camera and torch are off. | An explanation and a retry button (create a new session). |

The current state is available at any time (`session.state` on iOS, `session.state.value` or the `StateFlow` on
Android) and every transition is also delivered as a `state` event, with one exception: **the measuring clock
ticks are not transitions.** About once per second while measuring, the state value becomes
`measuring(elapsed)` with the new elapsed time, but no `state` event is emitted for it; read `elapsed` from the
`preview` event or from the state value instead. `completed` and `failed` arrive both as their own event and as
the following `state` event.

## Events

| Event | Payload | When |
| - | - | - |
| `state` | `VitalsSessionState` | On every state transition (not on measuring ticks). |
| `guidance` | `Guidance`, `startProgress` 0 to 1 | About once per second while positioning. `startProgress` is the start gate's fill. |
| `started` | | The start gate opened and a reading began. Reset your live UI. |
| `preview` | `LivePreview` | About once per second while measuring. |
| `heartbeat` | `ibiMs` (optional) | On each detected beat while measuring; `ibiMs` is the interval since the previous beat, absent for the first beat. Use it for a pulse animation or a light haptic. |
| `cancelled` | `CancelReason`, `CancelContext` | The attempt was abandoned. The session re-arms and returns to positioning by itself when `autoRestartAfterCancel` is true. |
| `reconfiguringCamera` | `lowPowerMode` | iOS only (Android runs one fixed tier and never emits it). The camera could not hold its frame rate and the SDK is switching to a lower tier in place. When `lowPowerMode` is true, ask the user to turn off Low Power Mode. |
| `recoverableError` | `VitalsRecoverableError` | A camera problem the SDK is recovering from: `frameRateUnsustainable(fps)` (Android) or `cameraStalled` (both). One recovery per session; a second recoverable error of any kind fails the session. |
| `measurementComplete` | | The full duration was captured. The result follows after finalizing. |
| `completed` | `VitalsResult` | The final result. |
| `failed` | `VitalsError` / `VitalsException` | Terminal error. |

**Delivery threads differ by platform.** iOS delivers every event on the main thread (`AsyncStream` or delegate),
in order, never synchronously from the caller. Android emits `events` and `state` on the SDK's engine thread:
collect them on the dispatcher you need (`lifecycleScope.launch { session.events.collect { ... } }` moves them to
the main thread), or pass an executor to `setListener`. The Android `SharedFlow` keeps 64 events per collector
and drops the oldest when a collector falls further behind, so `Completed`, `Failed` and `Cancelled` always land;
subscribe before `start()`, because events are not replayed.

## Positioning

While positioning, the session evaluates the signal about once per second and emits `guidance`. iOS discards the
first second of frames after the camera starts or reconfigures, while exposure settles. On Android the exposure
tuner runs while a finger is on the lens, and the session waits for exposure to settle before it can start
(`require_exposure_converged`, with a 15-second cap so a reading can always start).

A preview is **eligible** to start when there is finger contact, the quality score is at or above the start
floor, the frame rate is healthy, the pulse amplitude is at or above the minimum, and (Android) exposure has
settled or the wait has timed out. The reading starts when the start gate has seen enough eligible previews over
its window (3 s and every preview on iOS; 4 s and 75% on Android; see [defaults](#production-defaults)) and the
current preview is eligible. On start the SDK resets the signal, locks exposure (iOS; Android keeps its manual
capture configuration) and emits `started`.

A host `cancelReading` while positioning is ignored: there is no attempt to cancel.

### Guidance values and suggested copy

`Guidance` is an enum (Swift `noContact` ..., Kotlin `NO_CONTACT` ...); the SDK does not ship strings, so you
localize the copy. Per preview the first matching condition wins, in the order of the table. The copy column is
what the NEUROFIT app uses (its localization keys in parentheses) as a starting point; replace "NEUROFIT" with your
app's name.

| Guidance | Condition | NEUROFIT app copy |
| - | - | - |
| `noContact` / `NO_CONTACT` | No fingertip on the camera. | "With a warm fingertip, cover the back camera. Slowly adjust finger pressure until you see your pulse in the red glow." (`hrv_modal_cover_back_camera`) |
| `frameDrop` / `FRAME_DROP` | The camera is below its frame-rate floor (70% of the tier on iOS, 24 fps on Android). iOS reconfigures to a lower tier by itself; Android reports a recoverable error if it persists. | "Optimizing camera resolution and frame rate for reading quality..." (`hrv_modal_optimizing_camera_resolution`) |
| `weakSignal` / `WEAK_SIGNAL` | Contact, but the pulse amplitude is below `minAmplitude` (often a cold finger or too much pressure). With the Android default of 0.0 this value never appears on Android. | "Slowly adjust your finger pressure until you can see your pulse in the red glow." (`hrv_modal_compliance_adjust_pressure`) |
| `lowQuality` / `LOW_QUALITY` | Contact, but the quality score is below the start floor (movement, changing pressure). | "Slowly adjust your finger pressure until you can see your pulse in the red glow." (`hrv_modal_compliance_adjust_pressure`) |
| `compliant` / `COMPLIANT` | Eligible; the start gate is filling (`startProgress` 0 to 1). | "Starting your reading - hold still and keep the same finger pressure..." (`hrv_modal_compliance_hold_steady`) |

Two more strings from the app are useful outside positioning:

| Moment | NEUROFIT app copy |
| - | - |
| `started` through `measurementComplete` | "Measuring your Vitals - keep the same finger pressure, sit still, and relax for a minute..." (`hrv_modal_measuring_hrv`) |
| `reconfiguringCamera(lowPowerMode: true)` | "Adjusting camera resolution - please turn off Low Power Mode." (`hrv_modal_adjust_camera_disable_low_power`) |
| `failed(noHeartRate)` or a `withheld` result | "To ensure accurate results, NEUROFIT needs higher quality signal from your device. Please retry your reading." (`hrv_modal_higher_quality_reading_retry`) |

## Measuring

Once started, every preview first delivers its `preview` event (with the abort risk after this tick), then the
session runs these checks in order and cancels the attempt on the first that fires:

| Order | Check | Cancel reason |
| - | - | - |
| 1 | Finger contact lost for 3 consecutive previews. | `contactLost` |
| 2 | Too many failed quality checks in the recent window (12 s window, at least 8 samples, 60% bad). | `lowQuality` |
| 3 | 100 consecutive withheld live estimates. In practice this never fires inside a 60 s reading; it is observed for diagnostics. | `lowLiveSignalQuality` |
| 4 | At the 35th preview (about 35 s), no live estimate with a discard ratio below 0.5 has been seen in this attempt. | `noLowDiscardEstimate` |
| 5 | Your app called `cancelReading(reason)` while measuring. | `host(reason)` |

A cancelling tick still delivers its `preview` first, so the 35 s cancel is preceded by an `imminent` abort risk.

On cancel the SDK emits `cancelled(reason, context)`, resets the reading and unlocks exposure (iOS). With
`autoRestartAfterCancel` true (the default) it returns to positioning and re-arms automatically; you do not need
to restart anything. With it false the state becomes `idle`, the torch goes off and the camera is released, and you
call `start()` again on the same session when you are ready. `CancelContext` carries the elapsed time, the quality
score at the cancel and the camera's exposure operating point, for your logs; its `toDictionary()` / `toMap()`
has ten keys (`reason`, `quality_score`, `elapsed_s`, `engine`, `refine_mode`, `effective_fps`, `device_model`,
`red_dc`, `exposure_iso`, `exposure_tune_steps`, with `-1` for an absent number) plus `exposure_in_band` when it
is known.

The reading finishes when `elapsed` reaches `readingDuration` (60 s): the SDK emits `measurementComplete`, moves
to `finalizing`, computes the result off the main thread, builds the result, turns the torch off, stops the camera
and emits `completed(result)`. If no heart rate could be recovered the session ends with `failed(noHeartRate)`
instead of a result.

### Frame rate and camera health

* **iOS** runs the camera at 60 fps (or the highest tier the device reaches) and drops to 30, then 20, when the
  measured rate stays below 70% of the tier for two preview seconds. Each drop abandons the current attempt,
  reconfigures the camera in place, emits `reconfiguringCamera(lowPowerMode)` and returns to positioning (with a
  `state(positioning)` event if a reading was in progress). Below the last tier the session fails with
  `unsupportedDevice`. An `AVCaptureSession` runtime error is a recoverable `cameraStalled`: the session is rebound
  once; a second one fails with `cameraUnavailable`.
* **Android** runs a fixed 30 fps. While positioning, a measured rate below 24 fps for 6 consecutive previews
  emits `recoverableError(frameRateUnsustainable(fps))` and re-arms; while measuring, a low rate is recorded in the
  diagnostics (`frame_drop_compliance_error_count`) and never cancels. A camera that delivers no frames for two watchdog ticks (the watchdog runs every
  2 s from 4 s after the bind, foreground only) is rebound silently while positioning, up to 5 times; beyond the
  budget, or while measuring, it is a recoverable `cameraStalled`. A run of 60 black frames also triggers a rebind
  (same budget of 5, refilled by any non-black frame).
* **Both**: the recovery budget is one recoverable error per session, of any kind. The second recoverable error
  fails the session: `frameRateUnsustainable` with `unsupportedDevice(reason)`, `cameraStalled` with
  `cameraUnavailable`.

### Interruptions and backgrounding

A reading cannot survive the app leaving the foreground: on iOS `UIApplication.didEnterBackgroundNotification`,
an `AVCaptureSession` interruption (phone call, another app's camera use, system pressure) or a media-services
reset, and on Android the lifecycle owner's `ON_STOP`, fail a positioning or measuring session with
`interrupted(reason)`; for backgrounding the reason is `"The app moved to the background"` on both platforms. An
interruption that arrives while finalizing is ignored and the result is delivered. Android also calls `stop()`
when the owner is destroyed.

Because a rotation recreates an Android activity (and can briefly interrupt capture on iOS), **lock the reading
screen's orientation** for the duration of a reading, and keep the screen on.

### Live preview

`LivePreview` arrives about once per second while measuring:

| Field | Meaning |
| - | - |
| `elapsed` | Seconds since the attempt started. |
| `progress` | `elapsed / readingDuration`, clamped to 0 to 1. |
| `hrBpm` | Current heart rate, when available. |
| `rmssdMs` | Provisional HRV (RMSSD), available from about 20 s into the attempt and absent before. It keeps the last good value between updates. Show it as provisional; the final value can differ. |
| `signalLevel` | `strong`, `good`, `fair` or `weak`, from the live quality score (0.80, 0.65 and 0.50 thresholds). |
| `breathingRate` | Provisional breathing rate in breaths per minute. Absent until the estimate is ready, about 30 s into the attempt. |
| `abortRisk` | `none`, `warn` or `imminent`: how close the attempt is to the `noLowDiscardEstimate` cancel. `warn` from about 21 s, `imminent` from about 30 s, back to `none` as soon as a reliable estimate arrives. Use it to nudge the user to hold still before a cancel happens. |

## Failures

| iOS `VitalsError` / Android `VitalsException` | Meaning | Suggested handling |
| - | - | - |
| `notActivated` / `NotActivated` | `activate` was not called or failed. Thrown by session creation and by `start()`. | Fix activation; see [License keys](/license-keys). |
| `licenseInvalid`, `licenseNotValidForApp(appId)`, `licenseUpdatesExpired(updatesUntil)` | License problems, thrown by `activate`. | See [License keys](/license-keys). |
| `cameraDenied`, `cameraRestricted` | The user declined camera access (or, on iOS, a policy blocks it). Thrown by `start()`. `CameraRestricted` is not raised on Android. | Explain and link to Settings. |
| `cameraUnavailable` | The camera could not be opened. iOS: thrown by `start()`. Android: arrives asynchronously as `Failed(CameraUnavailable)` after `start()` returned. Also the outcome of a second camera stall. | Retry later with a new session. |
| `unsupportedDevice(reason)` | No rear camera or torch, no usable frame rate (thrown by session creation and `start()`), or the camera could not sustain its tier during a reading (asynchronous). | Hide the feature or explain; see [Device support](/device-support). |
| `interrupted(reason)` | Backgrounding, a phone call, another app's camera use, a media-services reset; on Android also a pending `awaitResult` when `stop()` is called first. | Create a new session when the user returns. |
| `noHeartRate` | The duration was captured but no heart rate could be recovered. | Offer a retry with the higher-quality-signal copy. |
| `alreadyRunning` | `start()` was called on a session that is already running. | Programming error. |
| `internalError(detail)` | An invalid configuration (`"Invalid configuration: ..."` / `"invalid configuration: ..."`), `start()` on a stopped or finished session, Android `start()` off the main thread, or an unexpected condition. | Log the detail and offer a retry with a new session. |

A failed `start()` leaves the session restartable on both platforms (except after `stop()` or a terminal state,
when a new session is needed).

## Configuration

`VitalsConfiguration` (iOS) and `VitalsConfig` (Android) have three top-level settings and an `advanced` block.
`readingDuration` is a `Double` in seconds on both platforms.

| Setting | Default | Notes |
| - | - | - |
| `readingDuration` | 60.0 s | The validated duration. Other durations are not validated; keep 60 s for outcomes work. Must be finite and in (0, 3600]. |
| `autoRestartAfterCancel` | `true` | Return to positioning after a cancel. When false the session goes to `idle` with the camera off and you call `start()` again. |
| `motionBreathing` | `true` | Use the accelerometer (50 Hz) to help estimate breathing rate. When false, or on a device without an accelerometer, the estimate comes from the pulse signal alone. |

Session creation validates the configuration and throws `internalError` (iOS: `"Invalid configuration: <detail>"`,
Android: `"invalid configuration: <detail>"`) for a non-finite number, a duration outside (0, 3600], a window or
deadline outside \[0, 3600] seconds, or a frame-rate tier outside 1 to 240 fps. The exact rules are on the API
reference pages.

### Production defaults

The `advanced` block holds every capture and gating parameter with the values the NEUROFIT app runs in
production. The parameters are listed here by their engine names; the Swift and Kotlin properties use the
camelCase form (`min_amplitude` is `minAmplitude`). Changing them is unvalidated; do so only with NEUROFIT's
guidance. Each SDK has a unit test that asserts these defaults.

| Parameter | iOS | Android |
| - | - | - |
| `min_amplitude` | 0.5 | 0.0 |
| `contact_enter_frac` / `contact_exit_frac` | 0.5 / 0.5 | 0.5 / 0.4 |
| `start_window_seconds` / `start_required_fraction` / `start_quality_floor` | 3.0 / 1.0 / 0.5 | 4.0 / 0.75 / 0.5 |
| `require_exposure_converged` / `start_max_wait_seconds` | false / 15.0 | true / 15.0 |
| `cancel_window_seconds` / `cancel_min_samples` / `cancel_bad_fraction` | 12.0 / 8 / 0.6 | 12.0 / 8 / 0.6 |
| `live_sq_cancel_consecutive` | 100 (observe only) | 100 (observe only) |
| `contact_loss_previews` | 3 | 3 |
| `live_discard_gate_seconds` / `live_discard_gate_threshold` | 35 / 0.5 | 35 / 0.5 |
| `exposure_tuner_enabled` | false (auto-exposure) | true |
| `exposure_target_dc` / `exposure_dc_min` / `exposure_dc_max` | 150 / 120 / 210 | 150 / 120 / 210 |
| `exposure_tune_frames_per_step` / `exposure_tune_max_steps` / `exposure_tune_gamma` / `exposure_tuner_max_cancels` | 15 / 8 / 1.0 / 2 | 15 / 8 / 1.0 / 2 |
| `consistency_gate` / `merged_interval_fix` / `android_estimator` | true / true / false | true / true / true |
| Reading duration | 60 s | 60 s |
| Frame-rate tiers | `fpsTiers = [60, 30, 20]`, dropping on frame drop | `fps = 30`, fixed |

There is no remote configuration in the SDK. The values above are compiled in.


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