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 helper | Public 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:
- Video-fed measurement — a full measurement from a recorded clip, asserting that real readings came out.
- 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
MediaMetadataRetrieveris a safe choice. - Bitmaps are converted to
ARGB_8888internally when needed. - Timestamps are microseconds, strictly increasing, on one time base for
the whole session — derive them from the clip (
index * frameIntervalUs, orMediaExtractorsample times).
See Android Metrics for which metrics to request and how to read them.
A CI pipeline, in general terms
- Expose the API key as a job secret and pass it to the test as an instrumentation argument.
- Run the instrumented test on an emulator — the runner needs hardware acceleration (KVM on Linux) for the emulator to boot in CI.
- 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.