DocsSmartSpectra
Browse documentation
Android / Kotlin

Headless Testing in CI on Android

View MarkdownView source

See Headless Testing in CI for the cross-platform overview of what’s automatable and why. This page covers the Android specifics.

What’s different on Android

The SDK normally measures from the live camera. Its public custom-input API also accepts frames decoded by your app or test. An emulator can run a recorded-video measurement without camera hardware or CAMERA permission:

val input = sdk.useCustomInput() // Select while stopped.
sdk.start()
val result = input.sendFrame(bitmap, timestampUs)

Unlike the iOS SDK, the Android SDK does not decode the file itself. Your test supplies decoded frames (for example via MediaMetadataRetriever, as below, or MediaCodec) with strictly increasing microsecond timestamps from the clip. See custom input for lifecycle, frame ownership, orientation, and timing requirements.

The earlier setVideoInputEnabled / addVideoFrame helpers remain testing-only and require @OptIn(SmartSpectraTestingApi::class). The public useCustomInput() API requires no opt-in.

To update an existing test to the public API:

Testing helperPublic custom-input API
sdk.setVideoInputEnabled(true)val input = sdk.useCustomInput() while stopped
sdk.addVideoFrame(bitmap, timestampUs)input.sendFrame(bitmap, timestampUs); handle the returned result
sdk.setVideoInputEnabled(false)Stop, then call sdk.useCamera(com.presagetech.smartspectra.CameraSelection.Default)

If you retain the testing helpers, enable video input while stopped and await start() before submitting frames. The helpers now throw SmartSpectraException on invalid source selection or a rejected frame; they no longer silently discard these errors. The public handle returns FrameSubmissionResult.Rejected instead.

Two levels of CI coverage, pick per test:

  1. Video-fed measurement — a full measurement from a recorded clip, asserting that real readings came out.
  2. Build-integration smoke — no clip needed; proves the SDK builds, launches, and initializes.

Option 1: The video-fed test

Drive SmartSpectraSdk.shared directly, the same way you would for any headless integration, and run it as an instrumented test on an Android emulator. Feed the clip, read the metrics LiveData, and assert that real readings appeared — a pulse rate and a breathing rate — not their exact values.

import android.media.MediaMetadataRetriever
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.presagetech.smartspectra.SmartSpectraConfig
import com.presagetech.smartspectra.SmartSpectraSdk
import com.presagetech.smartspectra.FrameSubmissionResult
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith

@RunWith(AndroidJUnit4::class)
class VideoMeasurementTest {
    @Test
    fun measuresFromRecordedVideo() = runBlocking {
        val sdk = SmartSpectraSdk.shared
        sdk.config.apiKey =
            InstrumentationRegistry.getArguments().getString("smartspectraApiKey").orEmpty()
        // The default request is breathing-only; ask for cardio too so a
        // pulse rate can appear. See the metrics guide.
        sdk.config.requestedMetrics =
            SmartSpectraConfig.breathingMetrics + SmartSpectraConfig.cardioMetrics

        val input = sdk.useCustomInput()
        val retriever = MediaMetadataRetriever()
        try {
            sdk.start()

            // A short clip of a well-lit, mostly still face, bundled in the
            // test APK's assets (assets are not compressed for .mp4).
            InstrumentationRegistry.getInstrumentation().context.assets
                .openFd("face.mp4").use { afd ->
                    retriever.setDataSource(afd.fileDescriptor, afd.startOffset, afd.declaredLength)
                }
            val frameCount = retriever.extractMetadata(
                MediaMetadataRetriever.METADATA_KEY_VIDEO_FRAME_COUNT)!!.toInt()
            val durationUs = retriever.extractMetadata(
                MediaMetadataRetriever.METADATA_KEY_DURATION)!!.toLong() * 1_000L
            val frameIntervalUs = durationUs / frameCount

            var sawPulse = false
            var sawBreathing = false
            for (index in 0 until frameCount) {
                val frame = retriever.getFrameAtIndex(index) ?: break
                val result = try {
                    input.sendFrame(frame, index * frameIntervalUs)
                } finally {
                    frame.recycle()
                }
                assertTrue("frame rejected: $result", result is FrameSubmissionResult.Accepted)
                sdk.metrics.value?.let { m ->
                    if (!sawPulse) sawPulse = m.cardio.pulseRateList.any { it.value > 0f }
                    if (!sawBreathing) sawBreathing = m.breathing.rateList.any { it.value > 0f }
                }
                if (sawPulse && sawBreathing) break
                // Emulators software-render the pipeline: feed no faster than
                // ~10 wall-clock fps so frames aren't dropped. This is the
                // delivery rate, not the capture rate — each frame carries its
                // own recorded timestamp, so the pipeline still sees the clip's
                // real >=25 fps cadence (see Getting a Good Measurement) and
                // computed rates are unaffected. Do not confuse this with
                // lowering the capture frame rate, which would raise
                // FRAME_RATE_TOO_LOW.
                Thread.sleep(100)
            }
            assertTrue("no pulse reading came out of the recorded clip", sawPulse)
            assertTrue("no breathing reading came out of the recorded clip", sawBreathing)
        } finally {
            try {
                retriever.release()
            } finally {
                // reset() is safe even if start() failed, and preserves custom input.
                sdk.reset()
                sdk.useCamera(com.presagetech.smartspectra.CameraSelection.Default)
            }
        }
    }
}

Option 2: The build-integration smoke

If you don’t have a recorded clip yet (or want a faster job on every push), skip the video calls entirely and keep the check at smoke level — no opt-in needed. This variant runs the normal camera path against the emulator’s simulated feed, so it grants the CAMERA permission:

import android.Manifest
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.rule.GrantPermissionRule
import com.presagetech.smartspectra.SmartSpectraException
import com.presagetech.smartspectra.SmartSpectraSdk
import kotlinx.coroutines.runBlocking
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith

@RunWith(AndroidJUnit4::class)
class HeadlessSmokeTest {
    @get:Rule
    val cameraPermission: GrantPermissionRule =
        GrantPermissionRule.grant(Manifest.permission.CAMERA)

    @Test
    fun sdkInitializesHeadless() = runBlocking {
        val sdk = SmartSpectraSdk.shared
        sdk.config.apiKey =
            InstrumentationRegistry.getArguments().getString("smartspectraApiKey").orEmpty()

        try {
            sdk.start()
            sdk.stop()
        } catch (e: SmartSpectraException) {
            println("SmartSpectra reported: ${e.message}")
        }
    }
}

The emulator’s simulated camera feed has no real face in it, so don’t assert on a measurement result here: start() returning at all — whether it succeeds or throws a typed SmartSpectraException — is the smoke signal that the SDK built, launched, and initialized correctly end to end.

The recorded video

Supply your own short clip and keep it in your test assets:

  • Around 30–60 seconds of a well-lit, mostly still face, framed like a real measurement — long enough for the pipeline to compute rates (a measurement runs about 30 seconds); a clip of only a few seconds won’t produce readings.
  • Any container/codec your decoder handles; MP4 (H.264) with MediaMetadataRetriever is a safe choice.
  • Bitmaps are converted to ARGB_8888 internally when needed.
  • Timestamps are microseconds, strictly increasing, on one time base for the whole session — derive them from the clip (index * frameIntervalUs, or MediaExtractor sample times).

See Android Metrics for which metrics to request and how to read them.

A CI pipeline, in general terms

  1. Expose the API key as a job secret and pass it to the test as an instrumentation argument.
  2. Run the instrumented test on an emulator — the runner needs hardware acceleration (KVM on Linux) for the emulator to boot in CI.
  3. Fail the job if the test APK doesn’t build or the test fails.

A minimal, provider-neutral sketch (GitHub Actions) using Gradle Managed Devices, which provisions and boots the emulator headlessly for you. Declare the device in your app module:

// build.gradle.kts
android {
    testOptions {
        managedDevices {
            localDevices {
                create("headlessVideo") {
                    device = "Pixel 8"
                    apiLevel = 34
                    systemImageSource = "aosp-atd"
                }
            }
        }
    }
}

Then run it in CI:

name: smartspectra-android-headless-video
on: [push]

jobs:
  headless:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-java@v4
        with:
          distribution: temurin
          java-version: 17

      - name: Enable KVM for the emulator
        run: |
          echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \
            | sudo tee /etc/udev/rules.d/99-kvm4all.rules
          sudo udevadm control --reload-rules
          sudo udevadm trigger --name-match=kvm

      - name: Video-fed measurement test
        run: |
          ./gradlew headlessVideoDebugAndroidTest \
            -Pandroid.testInstrumentationRunnerArguments.smartspectraApiKey="${{ secrets.SMARTSPECTRA_API_KEY }}"

Limitations

  • No offline mode. Like every SmartSpectra SDK, a measurement authenticates against the SmartSpectra service, so the runner needs network access.
  • Don’t mix inputs. Within one session, feed frames exclusively via the custom-input handle. Stop before selecting the camera again.
  • Smoke, not accuracy. A recorded-clip run confirms the integration and model pipeline end to end; it is not an accuracy benchmark.

Search quick starts, measurements, and API references.