DocsSmartSpectra
Browse documentation
Android / Kotlin

Headless Mode on Android

View MarkdownView source

The SDK doesn’t ship UI. SmartSpectraSdk.shared exposes LiveData for processingStatus, validationStatus, metrics, error, and (optionally) imageOutput. Observe them from your Fragment or Activity with observe(viewLifecycleOwner). The sample apps include a measurement UI; your own integration looks however you want.

Use this when you want to:

  • Monitor vitals in the background while the app shows other content
  • Build a custom measurement UI

Processing Status

Lifecycle states:

StatusMeaning
IdlePipeline is not running
StartingPipeline is initializing
RunningActively measuring — data is flowing
StoppingTeardown in progress, will return to Idle
ErrorSomething went wrong

Example

Use SmartSpectraSdk.shared directly for headless processing:

import android.Manifest
import android.os.Bundle
import android.content.pm.PackageManager
import android.view.View
import android.widget.ImageView
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import com.presagetech.smartspectra.CameraSelection
import com.presagetech.smartspectra.ProcessingStatus
import com.presagetech.smartspectra.SmartSpectraError
import com.presagetech.smartspectra.SmartSpectraSdk
import kotlinx.coroutines.launch

class HeadlessFragment : Fragment() {
    private val sdk by lazy {
        SmartSpectraSdk.shared.apply {
            config.apiKey = "YOUR_API_KEY"
            useCamera(CameraSelection.Front)
            config.imageOutputEnabled = true
        }
    }

    private val requestCameraPermission =
        registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
            if (granted) {
                startMonitoring()
            } else {
                showCameraPermissionUi()
            }
        }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        val previewImage: ImageView = view.findViewById(R.id.headless_preview_image)

        sdk.processingStatus.observe(viewLifecycleOwner) { status ->
            when (status) {
                ProcessingStatus.IDLE -> showIdleUi()
                ProcessingStatus.STARTING -> showLoadingUi()
                ProcessingStatus.RUNNING -> showRecordingUi()
                ProcessingStatus.STOPPING -> showStoppingUi()
                ProcessingStatus.ERROR -> showErrorUi()
            }
        }
        sdk.validationStatus.observe(viewLifecycleOwner) { status ->
            updateStatusHint(status?.hint.orEmpty())
        }
        sdk.imageOutput.observe(viewLifecycleOwner) { bitmap ->
            previewImage.setImageBitmap(bitmap)
        }
        sdk.metrics.observe(viewLifecycleOwner) { metrics ->
            renderMetrics(metrics)
        }
        sdk.error.observe(viewLifecycleOwner) { error ->
            if (error?.code == SmartSpectraError.Code.INPUT_UNAVAILABLE) {
                showCameraPermissionUi()
            }
        }
    }

    private fun startMonitoring() {
        if (ContextCompat.checkSelfPermission(requireContext(), Manifest.permission.CAMERA)
            != PackageManager.PERMISSION_GRANTED
        ) {
            requestCameraPermission.launch(Manifest.permission.CAMERA)
            return
        }

        viewLifecycleOwner.lifecycleScope.launch {
            sdk.start()
        }
    }

    private fun stopMonitoring() {
        viewLifecycleOwner.lifecycleScope.launch {
            sdk.stop()
        }
    }
}

Caller-owned camera and custom input

Use sdk.useCustomInput() when your app already captures video, such as a camera shared with a video call, or supplies decoded video frames. In this mode the SDK does not discover, open, or control a camera and does not require camera permission. Your app remains responsible for permissions needed by its own capture code.

Select the input while stopped, await start(), and then submit frames:

import com.presagetech.smartspectra.FrameSubmissionResult
import com.presagetech.smartspectra.FrameTransform
import com.presagetech.smartspectra.SmartSpectraSdk

val sdk = SmartSpectraSdk.shared
sdk.config.apiKey = "YOUR_API_KEY"
val input = sdk.useCustomInput(frameTransform = FrameTransform.NONE)

sdk.start() // Suspends until ready; call from a coroutine.
try {
    // Supply an upright Bitmap and its capture timestamp in microseconds.
    when (val result = input.sendFrame(bitmap, timestampUs)) {
        FrameSubmissionResult.Accepted -> Unit
        is FrameSubmissionResult.Rejected -> showFrameError(result.error)
    }
} finally {
    sdk.stop()
}

Accepted means the frame was submitted, not that its measurement has finished. Continue observing sdk.metrics, sdk.validationStatus, and sdk.error as with SDK-owned capture. A rejected submission carries a typed SmartSpectraError; do not assume every submitted frame was accepted.

Buffers and image ownership

Use VideoFrame.Packed for a direct pixel buffer, or VideoFrame.Yuv420 for three YUV planes:

import com.presagetech.smartspectra.FramePlane
import com.presagetech.smartspectra.VideoFrame

val frame = VideoFrame.Yuv420(
    width = width,
    height = height,
    y = FramePlane(yBuffer, yRowStride, 1),
    u = FramePlane(uBuffer, uRowStride, uvPixelStride),
    v = FramePlane(vBuffer, vRowStride, uvPixelStride),
)
val result = input.sendFrame(frame, timestampUs)

For packed pixels, supply VideoFrame.Packed(buffer, width, height, rowStride, pixelFormat) with a typed PixelFormat. Dimensions describe the supplied pixels; row and pixel strides are measured in bytes. Buffers must be direct ByteBuffer instances. Each buffer’s current position identifies the first byte and its limit bounds the readable region. Submission leaves those values unchanged.

The caller owns the buffers. Keep their pixels unchanged until sendFrame() returns; they can then be reused. The Bitmap overload is a convenience for callers without direct buffers.

The CameraX overload, input.sendFrame(imageProxy), borrows an ImageProxy and uses its capture timestamp. It accepts only YUV_420_888 with a full-image crop. Unrotated images borrow the planes directly; rotated images require a Bitmap copy. Always close the image yourself, including after rejection:

try {
    val result = input.sendFrame(imageProxy)
    if (result is FrameSubmissionResult.Rejected) {
        showFrameError(result.error)
    }
} finally {
    imageProxy.close()
}

CameraX capture example

The following helper binds a CameraX analyzer and submits each image to the SDK. Configure SDK authentication and grant camera permission before calling start(). Supply your ProcessCameraProvider, a worker Executor, and an error callback. Call start() and stop() serially from a coroutine, passing your LifecycleOwner, CameraSelector, and display rotation to start(). The error callback runs on the analyzer’s worker thread; dispatch UI updates to the main thread.

The helper closes each image after submission and unbinds only its own capture use case. Your app owns the supplied executor and its shutdown.

import androidx.camera.core.CameraSelector
import androidx.camera.core.ImageAnalysis
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.lifecycle.LifecycleOwner
import com.presagetech.smartspectra.FrameSubmissionResult
import com.presagetech.smartspectra.SmartSpectraError
import com.presagetech.smartspectra.SmartSpectraSdk
import java.util.concurrent.Executor
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.withContext

class CustomCameraInput(
    private val sdk: SmartSpectraSdk,
    private val provider: ProcessCameraProvider,
    private val worker: Executor,
    private val onFrameError: (SmartSpectraError) -> Unit,
) {
    private var analysis: ImageAnalysis? = null

    suspend fun start(owner: LifecycleOwner, selector: CameraSelector, displayRotation: Int) {
        check(analysis == null) { "Capture is already started" }
        val input = sdk.useCustomInput()
        sdk.start()
        try {
            withContext(Dispatchers.Main.immediate) {
                val capture = ImageAnalysis.Builder()
                    .setTargetRotation(displayRotation)
                    .setOutputImageRotationEnabled(true)
                    .setOutputImageFormat(ImageAnalysis.OUTPUT_IMAGE_FORMAT_YUV_420_888)
                    .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
                    .build()
                analysis = capture
                capture.setAnalyzer(worker) { image ->
                    try {
                        val result = input.sendFrame(image)
                        if (result is FrameSubmissionResult.Rejected) onFrameError(result.error)
                    } finally {
                        image.close()
                    }
                }
                provider.bindToLifecycle(owner, selector, capture)
            }
        } catch (error: Exception) {
            withContext(NonCancellable) { stop() }
            throw error
        }
    }

    suspend fun stop() {
        withContext(Dispatchers.Main.immediate) {
            analysis?.let { capture ->
                capture.clearAnalyzer()
                provider.unbind(capture)
            }
            analysis = null
        }
        sdk.stop()
    }
}

Orientation and timing

With FrameTransform.NONE, submit upright pixels in the orientation you want measured. The Bitmap and buffer overloads have no rotation metadata; normalize their pixels or select a fixed FrameTransform. The ImageProxy overload applies imageInfo.rotationDegrees (0, 90, 180, or 270 degrees) first, then the session transform; other rotation values are rejected. Avoid applying the same rotation twice. Use the same orientation convention throughout the run.

Supply strictly increasing timestamps in microseconds, on one monotonic timeline per run. Values must be nonnegative and less than Long.MAX_VALUE - 2. For camera timestamps in nanoseconds, divide by 1_000L; for decoded video use the clip’s presentation timestamps. A gap greater than two seconds is rejected. After a feed interruption that creates such a gap, call sdk.stop() and then sdk.start() before resuming submission. Do not rewrite live timestamps to hide the gap.

Switching sources and reusing handles

Call useCustomInput() and useCamera(selection) only while stopped. Source selection during processing throws SmartSpectraException. To return to SDK-owned capture, stop, call sdk.useCamera(CameraSelection.Default), then start. To require a specific camera, pass its discovered ID instead.

Handles survive stop/start and reset. Selecting another source—including another useCustomInput() call—invalidates them. Frames submitted while stopped or through an invalid handle are rejected.

Reset stops processing and clears measurement state while preserving the selected input and transform. Await reset, then start before submitting more frames.

Reading Metrics

sdk.metrics is the same LiveData property here as in any other integration — there’s no separate “headless” API. See Android Metrics for the metric request configuration and the field-by-field reading guide.

Search quick starts, measurements, and API references.