> ## 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 with torch (flash) | Rear camera with torch (flash) |
| 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: any Xcode that targets iOS 15. 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 required.** The fingertip reading depends on steady, bright light against the skin. A device with a
rear camera but no torch is reported as unsupported. This is why most iPads and many tablets are unsupported,
and why simulators and emulators are always unsupported.

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 Camera2 characteristics), so it is cheap to call at any time. `DeviceSupport` has exactly five
fields.

<CodeGroup>
  ```swift Swift theme={null}
  let support = VitalsSDK.deviceSupport()
  // support.isSupported  Bool
  // support.hasCamera    Bool    rear wide camera present
  // support.hasTorch     Bool    torch present (required)
  // support.fpsTier      Int?    highest reachable frame-rate tier (60 / 30 / 20), nil when none
  // support.reason       String? why isSupported is false, nil when supported
  ```

  ```kotlin Kotlin theme={null}
  val support = VitalsSDK.deviceSupport(context)
  // support.isSupported, support.hasCamera, support.hasTorch, support.fpsTier (30 or null), support.reason
  ```

  ```typescript Wrappers theme={null}
  const support = await NeurofitVitals.deviceSupport();
  // { isSupported, hasCamera, hasTorch, fpsTier, 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 | `The rear camera has no torch` | |
| 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 | `no torch (camera flash)` | `PackageManager.FEATURE_CAMERA_FLASH` 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 fps ranges) counts as
supported; the runtime frame-rate gate then decides during the reading.

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 tier `deviceSupport().fpsTier` reports (60 on current iPhones) and drops to 30,
  then 20, when the camera cannot hold 70% of the tier for two seconds (Low Power Mode is the usual cause). Each
  drop is reported with `reconfiguringCamera(lowPowerMode)` and the camera is reconfigured in place. A device that
  cannot hold the last tier fails with `unsupportedDevice`.
* **Android** runs a fixed 30 fps. Battery Saver and thermal throttling can lower it; while positioning, six
  consecutive previews under 24 fps report `recoverableError(frameRateUnsustainable(fps))` and the SDK re-arms
  once; a second recoverable error fails the session with `unsupportedDevice`. While measuring, a low rate is
  recorded in the diagnostics only.

Ask users to turn off Low Power Mode or Battery Saver before a reading if they hit these paths. The NEUROFIT app
shows "Adjusting camera resolution - please turn off Low Power Mode." 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 (capped at 15 s). Readings work either
way, and there is nothing you need to handle. The `diagnostics` on each result record the camera's operating
point, so you can see what a given device did.

## Lifecycle requirements

A reading needs the app in the foreground for the whole minute: backgrounding (iOS) or the lifecycle owner's
`ON_STOP` (Android) during positioning or measuring fails the session with `interrupted`. 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")`; the SDK reports the same string as `CancelContext.deviceModel`); on Android
`Build.MODEL` (for example `SM-G991B`; `CancelContext.deviceModel` prefixes the manufacturer, `Samsung 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 unsupported.
* 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 have no camera and are unsupported; test on a device.
* A device without an accelerometer still takes readings; the breathing rate then comes from the pulse signal
  alone and `diagnostics.motion_samples` is 0.


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