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

# Device support

> Supported OS versions and hardware, how the SDK reports support at runtime, and how to exclude specific device models.

## Requirements

| | iOS | Android |
| - | - | - |
| OS version | iOS 15 or later | Android 9 (API 28) or later |
| Coverage | about 99% of iPhones | about 97% of Android phones |
| Hardware | Rear wide camera; its torch (flash) when present | Rear camera; its torch (flash) when present |
| Frame rate | 60, 30 or 20 fps, the highest the camera reaches at 640 px or wider | 30 fps, fixed |
| Sensors | Accelerometer (optional, for breathing rate) | Accelerometer (optional, for breathing rate) |
| Toolchain | Binary: Xcode 27 or newer, the Xcode the release was built with (the xcframework ships a textual Swift interface). Source: Xcode 16 or newer | Your app: `minSdk 28`, JVM 17, Kotlin 2.0 or newer. The SDK itself is built with AGP 8.13.2, Gradle 8.14.3 and CameraX 1.5.0 |

Coverage figures: Statcounter, August 2026.

**The torch is used, not required.** The fingertip reading works best with steady, bright light against the skin,
so the SDK keeps the torch on for the whole reading. A device whose rear camera has no torch (most iPads, many
tablets) is still supported: it attempts the reading without one, so the signal depends on the room's light.
Simulators and emulators cannot take a reading; test on a device.

Native Swift and Kotlin, each adding under 1 MB to your app download.

## Checking support at runtime

Call `deviceSupport()` before showing the feature. It never opens the camera (iOS reads the capture formats,
Android the camera's characteristics), so it is cheap to call at any time. `DeviceSupport` has two fields.

<CodeGroup>
  ```swift Swift theme={null}
  let support = VitalsSDK.deviceSupport()
  // support.isSupported  Bool
  // support.reason       String? why isSupported is false, nil when supported
  ```

  ```kotlin Kotlin theme={null}
  val support = VitalsSDK.deviceSupport(context)
  // support.isSupported  Boolean
  // support.reason       String? why isSupported is false, null when supported
  ```

  ```typescript Wrappers theme={null}
  const support = await NeurofitVitals.deviceSupport();
  // { isSupported, reason }
  ```
</CodeGroup>

`reason` is a short English string for your logs (localize your own copy):

| Platform | `reason` | Meaning |
| - | - | - |
| iOS | `No rear wide camera` | No `builtInWideAngleCamera` at the back. |
| iOS | `The iOS Simulator has no rear camera` | Running on the Simulator. |
| iOS | `No capture format reaches 20 fps at 640 px` | No format at least 640 px wide reaches the lowest tier. |
| Android | `no rear camera` | `PackageManager.FEATURE_CAMERA` absent. |
| Android | `camera does not offer a 30 fps range` | The rear camera's AE target fps ranges do not include 30. |

On Android, a device whose camera characteristics cannot be read (or that advertises no frame-rate ranges)
counts as supported; the frame-rate check during positioning then decides.

Creating a session on an unsupported device throws `unsupportedDevice(reason)` (iOS `VitalsSession.init`, Android
`VitalsSDK.createSession`), and `start()` checks again, so the check is a convenience for your UI rather than a
gate you must implement.

## Frame rate and power modes

* **iOS** starts at the highest frame rate the camera reaches (60 fps on current iPhones). When the camera cannot
  hold it (Low Power Mode is the usual cause), the SDK adjusts the capture in place; a reading in progress is
  cancelled with `cancelled(interrupted)` and starts again. A device that still cannot keep up shows `frameDrop`
  guidance, and the reading waits until the rate recovers.
* **Android** runs a fixed 30 fps. Battery Saver and thermal throttling can lower it; while positioning, a low rate
  shows `frameDrop` guidance and holds the start until the rate recovers.

A low frame rate never ends a session on either platform; `unsupportedDevice` comes only from session creation
or `start()`.

Ask users to turn off Low Power Mode or Battery Saver before a reading if they see `frameDrop` often. The NEUROFIT
app shows "Optimizing camera resolution and frame rate for reading quality..." at that moment.

## Android camera capabilities

The SDK configures the camera for the reading (manual exposure where the device offers it, an exposure bias
elsewhere) and clears that configuration again when the session stops, so your own camera features start from
CameraX defaults afterwards. Devices differ in how many camera controls they expose; on devices with fewer
controls, positioning can take a few seconds longer while exposure settles. Readings work either way, and there is
nothing you need to handle.

## Lifecycle requirements

A reading needs the app in the foreground for the whole minute, but leaving it does not end the session.
Backgrounding or another app taking the camera (iOS: also a phone call, Split View or a media-services reset;
Android: the lifecycle owner's `ON_STOP`) cancels a reading in progress with `cancelled(interrupted)`, and the
session waits in positioning until the app is back in front, then resumes by itself. Lock the reading screen's
orientation (an Android rotation recreates the activity) and keep the screen on.

## Excluding device models (deny-list override)

The SDK has no remote configuration and no built-in list of excluded models: `deviceSupport()` answers from the
hardware alone. If a model misbehaves in your user base, exclude it on your side, in front of the SDK, so you
can change the list without shipping a new build. This is how the NEUROFIT app does it: a list of model tokens
in its own remote config, matched case-insensitively by containment against the device model identifier, so an
entry can be an exact model or a family prefix.

Model identifiers: on iOS the machine identifier (`iPhone14,2` style, from `uname` or
`sysctlbyname("hw.machine")`); on Android `Build.MODEL` (for example `SM-G991B`).

<CodeGroup>
  ```swift Swift theme={null}
  /// Your list, from a constant or your own remote config. Empty excludes nothing.
  let deniedModels: [String] = ["iPad"]

  func deviceModelIdentifier() -> String {
    var systemInfo = utsname()
    uname(&systemInfo)
    return withUnsafePointer(to: &systemInfo.machine) {
      $0.withMemoryRebound(to: CChar.self, capacity: 1) { String(cString: $0) }
    }
  }

  func isModelDenied(_ model: String, denied: [String]) -> Bool {
    let m = model.lowercased()
    return denied.contains { token in
      let t = token.lowercased()
      return !t.isEmpty && (m.contains(t) || t.contains(m))
    }
  }

  let vitalsAvailable = VitalsSDK.deviceSupport().isSupported
    && !isModelDenied(deviceModelIdentifier(), denied: deniedModels)
  ```

  ```kotlin Kotlin theme={null}
  // Your list, from a constant or your own remote config. Empty excludes nothing.
  val deniedModels: List<String> = listOf("SM-A155")

  fun isModelDenied(model: String, denied: List<String>): Boolean {
    val m = model.trim().lowercase()
    return m.isNotEmpty() && denied.any { token ->
      val t = token.trim().lowercase()
      t.isNotEmpty() && (m.contains(t) || t.contains(m))
    }
  }

  val vitalsAvailable = VitalsSDK.deviceSupport(context).isSupported &&
    !isModelDenied(Build.MODEL, deniedModels)
  ```

  ```typescript Wrappers theme={null}
  // Read the model with your device-info library of choice (e.g. react-native-device-info, @capacitor/device).
  const deniedModels = ['SM-A155'];
  const isModelDenied = (model: string) => {
    const m = model.trim().toLowerCase();
    return m.length > 0 && deniedModels.some((token) => {
      const t = token.trim().toLowerCase();
      return t.length > 0 && (m.includes(t) || t.includes(m));
    });
  };
  const vitalsAvailable = (await NeurofitVitals.deviceSupport()).isSupported && !isModelDenied(deviceModel);
  ```
</CodeGroup>

When the check fails, do not create a session: hide the feature or show your own "not available on this device"
copy. Keep the list short and dated, and remove entries once a fix ships.

## What the validation covered

The validation study ran on 21 iPhone and 9 Android participants' own phones. Accuracy on iOS was 2.81 ms mean
absolute HRV (RMSSD) error and on Android 4.58 ms; see [Metrics](/metrics). Devices outside the study are
supported when they meet the requirements above; the SDK's start gate and quality tier are what protect the
numbers on any given phone, and the `withheld` tier is what tells you when they could not.

## Known limitations

* iPads and tablets without a torch are supported, but their readings rely on the room's light, so the signal is
  weaker than with a torch.
* Cases and lens protectors that separate the flash from the lens can weaken the signal. Suggest removing a thick
  case if readings keep restarting with `weakSignal` (iOS) or `lowQuality`.
* Cold fingers give a faint pulse. Suggest warming the hands first.
* Simulators and emulators cannot take a reading (the iOS Simulator has no camera; an emulator's virtual camera
  sees no fingertip); test on a device.
* A device without an accelerometer still takes readings; the breathing rate then comes from the pulse signal
  alone.


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