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.
States
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
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 emitsguidance. 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) 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.
Two more strings from the app are useful outside positioning:
Measuring
Once started, every preview first delivers itspreview 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:
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 astate(positioning)event if a reading was in progress). Below the last tier the session fails withunsupportedDevice. AnAVCaptureSessionruntime error is a recoverablecameraStalled: the session is rebound once; a second one fails withcameraUnavailable. - 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 recoverablecameraStalled. 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:
frameRateUnsustainablewithunsupportedDevice(reason),cameraStalledwithcameraUnavailable.
Interruptions and backgrounding
A reading cannot survive the app leaving the foreground: on iOSUIApplication.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:
Failures
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.
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
Theadvanced 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.
There is no remote configuration in the SDK. The values above are compiled in.