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

> How a session moves from positioning to a result, the eight events it emits, the guidance values with suggested copy, and how it handles cancels and interruptions.

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

## Phases

```
start() -> positioning -> measuring -> finalizing -> completed
                ^              |
                +--------------+   cancelled(reason): back to positioning, then a new reading starts by itself
failed(error): the session ends without a result
```

Your app follows the phases through the events. There is no separate state to poll.

| Phase | Begins with | What to show |
| - | - | - |
| Positioning | `start()` returning. `guidance` events about once per second. | The current guidance and, once compliant, the start progress. |
| Measuring | `started`. `preview` about once per second, `heartbeat` per beat. | Live values, a progress ring, "hold still". |
| Finalizing | `measurementComplete`. The torch stays on until the result is built. | A short "finishing" state, usually 1 to 3 seconds. |
| Completed | `completed(result)`. The camera and torch are off. | Your result screen. |
| Failed | `failed(error)`. The camera and torch are off. | An explanation and a retry button (create a new session). |

A cancel, an interruption or a camera problem does not end the session: the SDK abandons the attempt, waits or
restarts the camera, and returns to positioning by itself. Only the errors under [Failures](#failures) end it.

## Events

Every session emits exactly these eight events.

| Event | Payload | When |
| - | - | - |
| `guidance` | `Guidance`, `startProgress` 0 to 1 | About once per second while positioning. `startProgress` fills while the fingertip is steady; the reading starts on its own when it is full. |
| `started` | | A reading began. Reset your live UI. A `preview` at progress 0, with no values yet, follows at once. |
| `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. Use it for a pulse animation or a light haptic. |
| `cancelled` | `CancelReason` | The attempt was abandoned. The session returns to positioning, guidance flows again, and the next reading starts by itself once the fingertip is steady. |
| `measurementComplete` | | The full 60 seconds were captured. The result follows after finalizing. |
| `completed` | `VitalsResult` | The result. The session has ended. |
| `failed` | `VitalsError` / `VitalsException` | The session ended without a result. |

**Delivery differs by platform.** On iOS the SDK yields every event on the main thread, in order, through the
session's `AsyncStream`, which ends after `completed` or `failed` and on `stop()`. A `for await` loop runs on its
task's executor, so iterate `events` from the main actor (for example a `Task` started inside a `@MainActor` type)
to handle events on the main thread. Android emits `events` on the SDK's own thread: collect it on the dispatcher
you need (`lifecycleScope.launch { session.events.collect { ... } }` moves it to the main thread). The Android flow
keeps 64 events per collector and drops the oldest when a collector falls further behind, so `Completed`, `Failed`
and `Cancelled` always arrive; it is not replayed, so subscribe before `start()`, and it does not complete, so
cancel your collection when you are done.

## Positioning

While positioning, the session checks the fingertip signal about once per second and emits `guidance`. Once the
signal is steady, `startProgress` fills over a few seconds, and the reading starts by itself when it is full.
There is nothing for your app to call.

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

### Holding the start

`isAutoStartEnabled` is true by default (iOS: a `Bool` property; Android: a `var`, `setAutoStartEnabled` from
Java). While it is false the session keeps positioning, with the camera, the torch and the guidance running and
`startProgress` filling as usual, but no reading starts. Use it while something covers the reading screen, such as
an intro or a coaching sheet. A reading already measuring is unaffected; after its cancel the session returns to
positioning and waits. Setting it back to true lets the next steady moment start the reading. It can be set from
any thread, at any time (before `start()` too), and it belongs to that session only.

### 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. 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 | Meaning | 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 running below its frame rate, usually because of Low Power Mode or Battery Saver. The reading does not start while it shows. | "Optimizing camera resolution and frame rate for reading quality..." (`hrv_modal_optimizing_camera_resolution`) |
| `weakSignal` / `WEAK_SIGNAL` | Contact, but the pulse is faint (often a cold finger or too much pressure). Currently reported on iOS only. | "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 signal is not yet steady (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` | Steady; `startProgress` is filling. | "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`) |
| `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, the session emits a `preview` about once per second and a `heartbeat` per detected beat. It watches
the signal throughout, and abandons an attempt it cannot finish reliably rather than run the full minute and
withhold the result. Every abandoned attempt gets exactly one `cancelled` event, with one of four reasons:

| Cancel reason | What happened | Suggested note |
| - | - | - |
| `contactLost` / `CONTACT_LOST` | The fingertip left the camera for a few seconds. | "Keep your fingertip resting on the camera for the whole minute." |
| `poorSignal` / `POOR_SIGNAL` | The signal stayed too poor to finish a reliable reading: movement, changing pressure, or a faint pulse. `signalLevel` drops in the previews before it happens. | "Let's try that again. Rest the phone on a table and keep still." |
| `interrupted` / `INTERRUPTED` | The capture was interrupted or the camera was restarted (see [Interruptions](#interruptions-and-camera-health)). | Usually nothing; the reading starts again once the app is back. |
| `cancelledByApp` / `CANCELLED_BY_APP` | Your app called `cancelReading()` while measuring. | Your own copy, if any. |

After a cancel the session resets the attempt and returns to positioning by itself; you do not need to restart
anything. Guidance flows again and the next reading starts once the fingertip is steady (unless you are holding the
start with `isAutoStartEnabled`).

The reading finishes after 60 seconds: the session emits `measurementComplete`, computes the result off the main
thread, 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.

**`stop()` never loses a finished reading.** Once `measurementComplete` has arrived, `stop()` releases the camera
and torch at once and the session still delivers `completed(result)` (or `failed(noHeartRate)`) before it ends.
On iOS the session keeps itself alive until it has delivered, even if you drop your reference; keep your event
subscription until that event arrives. A `stop()` while positioning or measuring ends the session without a
result.

### Live preview

`LivePreview` arrives about once per second while measuring. The first one of each attempt follows `started` at
once, with `progress` 0 and no values, so your live UI can open at 0%.

| Field | Meaning |
| - | - |
| `progress` | 0 to 1 of the reading's 60 seconds. |
| `heartRateBpm` | 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`, ready to display as it is: smoothed over the last few seconds and lowered ahead of a poor-signal cancel. Absent until the attempt's first scored preview, so the start preview has none. |

## Interruptions and camera health

An interruption pauses the capture without ending the session. The triggers:

* **iOS**: the app moving to the background, a phone call, Split View or Slide Over, another app using the camera,
  system pressure, or a media-services reset.
* **Android**: the lifecycle owner's `ON_STOP`, or another app taking the camera.

What the session does:

1. If a reading is measuring, it emits `cancelled(interrupted)`.
2. It waits in positioning with no guidance.
3. When the cause clears (the app is active again, the camera is free again), the capture restarts by itself and
   guidance flows again.

Interruptions never fail the session on their own, and one that arrives after `measurementComplete` does not
affect the result. A wait the system controls, such as another app holding the camera, has no time limit. Once
the SDK is trying to restore the capture, it gives up after 10 seconds of failed recovery while the app is in the
foreground and ends the session with `failed(cameraUnavailable)`, instead of retrying forever. Android also stops
the session when the lifecycle owner is destroyed.

Camera health is handled the same way:

* **Frame rate.** While positioning, a camera below its frame rate shows `frameDrop` guidance and the reading
  waits until it recovers. On iOS the SDK also adjusts the capture in place when the camera cannot keep up; a
  reading in progress is then cancelled with `cancelled(interrupted)` and starts again. A low frame rate never
  ends a session.
* **Stalls.** A camera that stops delivering frames is restarted once (a reading in progress is cancelled with
  `cancelled(interrupted)`). A second stall in the same session ends it with `failed(cameraUnavailable)`.

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.

## 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(reason)` / `LicenseInvalid` | The key was rejected. Thrown by `activate`. `reason` names the check that failed. | See [License keys](/license-keys). |
| `cameraPermissionDenied` / `CameraPermissionDenied` | Camera access was declined, or a device policy blocks it. Thrown by `start()`. | Explain and link to Settings. |
| `cameraUnavailable` / `CameraUnavailable` | The camera could not be opened, or it stopped and could not be restored. iOS: thrown by `start()` or delivered as `failed`. Android: delivered as `Failed` after `start()` returned. | Retry later with a new session. |
| `unsupportedDevice(reason)` / `UnsupportedDevice` | No rear camera, or no usable frame rate. Thrown by session creation and `start()` only. | Hide the feature or explain; see [Device support](/device-support). |
| `noHeartRate` / `NoHeartRate` | The 60 seconds were captured but no heart rate could be recovered. | Offer a retry with the higher-quality-signal copy. |
| `internalError(message)` / `InternalError` | `start()` on a session that is already running or has ended, Android `start()` off the main thread, or an unexpected condition. | Log the message and offer a retry with a new session. |

A failed `start()` leaves the session as it was on both platforms, so you can call it again once the cause is
fixed (except after `stop()`, `completed` or `failed`, when a new session is needed).

## Settings

There is no configuration, local or remote. Every session runs the validated 60-second reading with the same
settings the NEUROFIT app uses in production; they are compiled into the SDK, so a reading behaves the same in
every app that embeds it.


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