Skip to main content
Each entry names the symptom you see, what is usually behind it, and what to do. When you contact contact@neurofit.app, include the SDK version, the platform and OS version, the device model and, for a reading problem, the result’s diagnostics or the CancelContext. Turning on the SDK’s own logging (VitalsSDK.isLoggingEnabled = true on iOS, VitalsSDK.loggingEnabled = true on Android; both off by default) records state transitions and capture decisions, never metric values, frames or license contents.

Activation and license

The key string is malformed, was edited, or was signed for another product. Check that the whole string was copied (three segments separated by dots, starting with NFV1.), with no line breaks, padding or surrounding quotes added by your config system. Leading and trailing spaces, tabs and newlines are trimmed; anything else inside the string is not. If it still fails, ask NEUROFIT to re-issue the key.
The running app’s bundle identifier or application id is not on the key. Compare appId in the error with the apps you asked for; the comparison is exact and case-sensitive. The relaxation to a warning works differently on the two platforms: on Android it is decided at runtime by the host app being debuggable (FLAG_DEBUGGABLE), so a debug flavour with a different id activates with a warning; on iOS it is compiled into the SDK module with #if DEBUG, so it applies only when you build the SDK from source in a Debug configuration, never with the Release-built xcframework. An iOS process without a bundle identifier (a bare test runner) fails with appId empty in Release builds; a Debug build of the SDK sources accepts it with the same relaxed-app-id warning. Ask NEUROFIT to add the identifier; you receive a new key.
This SDK build was released after your maintenance period ended. Either stay on the last SDK build dated on or before updatesUntil, or renew maintenance and receive a key with a later date. Apps you have already shipped keep working; see License keys.
activate was not called, or it threw and the error was swallowed. Activate once at launch and log the outcome. start() rechecks activation, so it throws the same error if activation never happened.

Permissions and camera

The user declined camera access, or a device policy blocks it (cameraRestricted, iOS only). Explain why the reading needs the camera and link to Settings (UIApplication.openSettingsURLString on iOS; Settings.ACTION_APPLICATION_DETAILS_SETTINGS on Android). On Android, a second refusal may be permanent; check shouldShowRequestPermissionRationale before asking again. A failed start() leaves the session restartable, so you can call it again once access is granted.
The SDK registers its launcher on the activity instance that asked. If that activity is recreated while the system dialog is showing (a rotation, a split-screen resize), the new instance has no registration and the callback is never called. Lock the orientation of the screen that asks, and also check VitalsSDK.hasCameraPermission(context) in onResume rather than relying on the callback alone.
Another app holds the camera, or the camera failed to open or bind. On iOS start() throws it; on Android start() returns normally and the session fails asynchronously with Failed(CameraUnavailable) through state and events (awaitResult throws it). It is also the outcome of a second camera stall in one session. Retry after a moment with a new session. On Android the SDK binds only its own CameraX use cases and unbinds only those, so your own camera use cases are left alone; still, release a camera you hold yourself before starting a reading.
On iOS the SDK asks for full torch level and falls back to the standard on state if the device refuses the level, and re-asserts the torch 0.4 s after the camera starts; on Android the torch is binary. If the torch stays off: check deviceSupport().hasTorch, close other apps that may hold the flash, and let the phone cool down (iOS disables the torch when the device is hot). diagnostics.torch_active records whether the torch was on at the end of the reading.
The app left the foreground (reason "The app moved to the background"), a phone call or another app took the camera, or media services were reset. On Android the same failure fires when the lifecycle owner you passed to start reaches ON_STOP while positioning or measuring, which includes an activity being recreated for a rotation. Lock the reading screen’s orientation, keep the screen on, and create a new session when the user returns. Readings cannot resume mid-way. An interruption during finalizing is ignored and the result is still delivered.
This should not happen with 1.0.0: the SDK clears the Camera2 interop options it set (AE/AF off, manual exposure and ISO, AWB lock, linear tonemap) on the shared per-camera control before it unbinds, and again when it binds, so the host’s next use of the rear camera starts from CameraX defaults. If you see it, make sure the session’s stop() ran (it is idempotent, so call it from your screen’s teardown) and send NEUROFIT the device model.

Positioning never starts the reading

The camera does not see a fingertip. Common causes: the finger is over the wrong lens (phones with several rear cameras; the reading uses the main wide camera), a thick case or lens protector, or the torch is off. Show a small camera preview so the user can see the red glow when the finger is placed right.
The pulse is faint. Cold hands are the usual cause; pressing hard is the second (it squeezes the blood out of the fingertip). Suggest warming the hands and resting the finger lightly. A thick case can also weaken the signal. Android does not enforce a minimum amplitude by default, so this value does not appear there.
The signal is there but unstable: the finger is moving, the pressure keeps changing, or the phone is being held in the air with a tense arm. Rest the phone on a table or the lap and hold the finger still.
The camera cannot hold its frame rate. On iOS this is almost always Low Power Mode; after two seconds below 70% of the tier the SDK drops to a lower tier and reports lowPowerMode so you can ask the user to turn it off. On Android, Battery Saver or thermal throttling; six consecutive previews under 24 fps while positioning report recoverableError(frameRateUnsustainable(fps)) once, and a second recoverable error fails the session with unsupportedDevice. A very old or very slow device can also cause it.
Android waits for exposure to settle before it starts, with a 15-second cap. Devices without manual sensor control settle more slowly. This is expected; show the compliant guidance and the start progress so the wait is visible.
cancelReading applies only while measuring. While positioning there is no attempt to cancel and the call is ignored (the SDK logs it when logging is on). Use stop() to leave the screen.

The reading keeps restarting

The finger lifted or slid for three consecutive seconds. Ask the user to keep the fingertip resting on the lens for the whole minute, and to avoid talking or adjusting their grip.
Too much of the last 12 seconds had a poor signal: movement, changing pressure, or the phone shifting. The same fixes as for lowQuality guidance. Use LivePreview.abortRisk to warn the user before a cancel fires.
By 35 seconds the SDK had not yet seen a reliable live estimate, so it stopped rather than run to 60 seconds and withhold the result. abortRisk moves to warn at about 21 s and imminent at about 30 s before this cancel. It usually means a faint or noisy pulse for the whole attempt: cold fingers, hard pressure, or a device with weak optics under the case. Warm hands and a lighter touch fix most of these.
Set autoRestartAfterCancel = false. After a cancel the session emits cancelled, goes to idle, turns the torch off and releases the camera; call start() on the same session when you are ready (on Android with the same or a new lifecycle owner). Most apps keep the default and show a short “let’s try that again” note.
The SDK recovers from one recoverable error per session, of any kind (frameRateUnsustainable, cameraStalled). The second one fails the session: with unsupportedDevice for a frame-rate problem, with cameraUnavailable for a stall. Create a new session; if the device does it consistently, send NEUROFIT the model and the diagnostics.

Results

The SDK could not stand behind the HRV (RMSSD) and related metrics for this reading. In the validation study this happened to 3.3% of readings. Show a calm retry prompt (the NEUROFIT app uses “To ensure accurate results, NEUROFIT needs higher quality signal from your device. Please retry your reading.”). Do not store it as an outcome. If one user is withheld repeatedly, check the fixes under positioning above; if one device model is withheld across users, send NEUROFIT the diagnostics.
60 seconds were captured but no heart rate could be recovered. This is rare and almost always a finger that was not really on the lens (the start gate passed on a brief good stretch). Offer a retry with a preview visible.
The breathing estimate was recovered from a weaker signal. Show it as an estimate or hide it. The validated breathing-rate accuracy is for paced breathing at 6 breaths per minute; free breathing is more variable.
Both are provisional and gated: the live HRV (RMSSD) appears from about 20 s into an attempt and the live breathing rate from about 30 s. Before that they are absent on purpose. Every cancel restarts the clock.
Expected. The live value is computed from a recent 30 s window and updated as it goes; the final value uses the whole minute with the full quality checks. Always store the final value.
Both are dropped when the reading fails the plausibility check (see Metrics), and sd2sd1 is also absent when SDNN is missing or the ratio is undefined. Treat them as optional.
Wearables report nightly or multi-hour averages; a seated daytime reading is a different moment, and RMSSD is strongly affected by breathing (slow breathing raises it). Compare like with like: a morning seated reading with the previous morning seated reading. See Outcomes best practices.

Build and integration

Make sure the xcframework is embedded in the app target (Embed and Sign) or that the local package product is linked to the target that imports it. With a source license, the PpgCore package must sit next to the SDK package at the relative path the Package.swift expects (../../ports/swift from sdk/ios).
Building the SDK from source needs Xcode 16 or newer: the module uses access-level imports (internal import PpgCore) so the engine stays out of its public interface. The binary xcframework has no toolchain requirement beyond iOS 15 deployment.
Several installed runtimes can match name=iPhone 15. Pick the simulator by id instead:
Any available iPhone works for the package tests; the Simulator has no camera, so a reading itself needs a device (deviceSupport() reports “The iOS Simulator has no rear camera”).
The artifact is not in any repository your build resolves. Publish it to your artifact repository, or add the delivered Maven folder as a maven { url = uri("...") } repository, or install it to mavenLocal() (the delivered build_aar.sh runs publishToMavenLocal) and add mavenLocal() to repositories.
VitalsSession.start binds CameraX to your lifecycle owner and must run on the main looper. Call it from the main thread (runOnUiThread, lifecycleScope.launch on Dispatchers.Main). cancelReading and stop can be called from any thread.
The AAR ships consumer-rules.pro, which keeps the public API in com.neurofit.vitals.* (the top-level package only; the internals under .capture, .license, .reading and the engine under ppgcore.* are meant to be shrunk). If you use a custom R8 configuration that ignores consumer rules, copy the rules listed on the Android API reference.
events and state are emitted on the SDK’s engine thread. Collect them inside lifecycleScope.launch (or another coroutine on Dispatchers.Main) before touching views, or pass ContextCompat.getMainExecutor(context) to setListener.
A hot reload can leave a native session running with no JavaScript or Dart listener. Call stop() in your cleanup and create a new session when the screen mounts again.