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

# Troubleshooting

> Symptoms, causes and fixes for activation, permissions, camera, signal, result and build problems.

Each entry names the symptom you see, what is usually behind it, and what to do. When you contact
[contact@neurofit.app](mailto: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

<AccordionGroup>
  <Accordion title="activate throws licenseInvalid">
    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.
  </Accordion>

  <Accordion title="activate throws licenseNotValidForApp(appId)">
    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.
  </Accordion>

  <Accordion title="activate throws licenseUpdatesExpired(updatesUntil)">
    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](/license-keys).
  </Accordion>

  <Accordion title="Creating a session or calling start throws notActivated">
    `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.
  </Accordion>
</AccordionGroup>

## Permissions and camera

<AccordionGroup>
  <Accordion title="start throws cameraDenied or cameraRestricted">
    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.
  </Accordion>

  <Accordion title="Android: requestCameraPermission never calls back">
    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.
  </Accordion>

  <Accordion title="cameraUnavailable">
    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.
  </Accordion>

  <Accordion title="The torch does not turn on">
    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.
  </Accordion>

  <Accordion title="Session fails with interrupted(reason)">
    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.
  </Accordion>

  <Accordion title="Android: my own camera opens dark or colour-locked after a reading">
    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.
  </Accordion>
</AccordionGroup>

## Positioning never starts the reading

<AccordionGroup>
  <Accordion title="Guidance stays on noContact">
    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.
  </Accordion>

  <Accordion title="Guidance alternates between weakSignal and compliant (iOS)">
    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.
  </Accordion>

  <Accordion title="Guidance stays on lowQuality">
    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.
  </Accordion>

  <Accordion title="Guidance shows frameDrop, or reconfiguringCamera keeps firing">
    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.
  </Accordion>

  <Accordion title="Android: positioning takes 10 to 15 seconds even with a good signal">
    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.
  </Accordion>

  <Accordion title="cancelReading does nothing">
    `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.
  </Accordion>
</AccordionGroup>

## The reading keeps restarting

<AccordionGroup>
  <Accordion title="cancelled(contactLost)">
    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.
  </Accordion>

  <Accordion title="cancelled(lowQuality)">
    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.
  </Accordion>

  <Accordion title="cancelled(noLowDiscardEstimate)">
    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.
  </Accordion>

  <Accordion title="I want to stop the auto-restart">
    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.
  </Accordion>

  <Accordion title="recoverableError fired, then the session failed">
    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.
  </Accordion>
</AccordionGroup>

## Results

<AccordionGroup>
  <Accordion title="quality is withheld">
    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.
  </Accordion>

  <Accordion title="failed(noHeartRate)">
    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.
  </Accordion>

  <Accordion title="breathingRateBrpm is present but breathingRateConfidence is under 0.5">
    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.
  </Accordion>

  <Accordion title="LivePreview.rmssdMs or breathingRate stays empty">
    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.
  </Accordion>

  <Accordion title="rmssdMs differs from the provisional value in LivePreview">
    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.
  </Accordion>

  <Accordion title="sd2sd1 or baevskyStressIndex is missing while rmssdMs is present">
    Both are dropped when the reading fails the plausibility check (see [Metrics](/metrics)), and `sd2sd1` is also
    absent when SDNN is missing or the ratio is undefined. Treat them as optional.
  </Accordion>

  <Accordion title="HRV (RMSSD) reads higher than the user's wearable">
    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](/outcomes-best-practices).
  </Accordion>
</AccordionGroup>

## Build and integration

<AccordionGroup>
  <Accordion title="iOS: 'no such module NeurofitVitals'">
    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`).
  </Accordion>

  <Accordion title="iOS: the SDK sources fail to compile with 'internal import'">
    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.
  </Accordion>

  <Accordion title="iOS: xcodebuild says the simulator destination is ambiguous">
    Several installed runtimes can match `name=iPhone 15`. Pick the simulator by id instead:

    ```bash theme={null}
    xcodebuild -scheme NeurofitVitals \
      -destination "id=$(xcrun simctl list devices available | grep -m1 'iPhone 15 (' | sed -E 's/.*\(([0-9A-F-]+)\).*/\1/')" \
      test
    ```

    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").
  </Accordion>

  <Accordion title="Android: 'Could not find com.neurofit:vitals-sdk:1.0.0'">
    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`.
  </Accordion>

  <Accordion title="Android: start() throws InternalError 'must be called on the main thread'">
    `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.
  </Accordion>

  <Accordion title="Android: R8 strips something and the SDK crashes in release">
    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](/api-reference-android#proguard--r8).
  </Accordion>

  <Accordion title="Android: events arrive on an unexpected thread">
    `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`.
  </Accordion>

  <Accordion title="Wrappers: events stop arriving after a hot reload">
    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.
  </Accordion>
</AccordionGroup>


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