Option 1: API Key
Fast manual SmartSpectra Android setup using an API key.
Use this if you want the fastest manual path.
What you will change manually
You will touch exactly these things:
- The app Gradle repositories and dependencies
app/src/main/java/com/example/coolvitals/MainActivity.kt
You do not need to create XML layouts or additional Kotlin files.
Result you should get
At the end, the app should show:
- live camera preview
- pulse rate, breathing rate, HRV RMSSD, and expression cards
- arterial pressure waveform
- chest and abdomen breathing waveforms
- status text and a start/stop button
- one portrait screen with no scrolling
Register for your free API Key
Create an Account
- Navigate to the Presage Developer Admin Service Portal
- Click Register and fill in your email, password, and other required fields.
- Check your email for a confirmation link and follow it to activate your account.
Log In
- Go to the Presage Developer Admin Portal Login
- Enter your email and password, then click Submit.
- After successful login you will be redirected to your Portal page, where you can manage your API key.
Step 1 — Create the project
In Android Studio, create a new Android app project:
- Select
File→New→New Project... - Choose
Empty Activity - Set
NametoCool Vitals - Set
Package nametocom.example.coolvitals - Set
LanguagetoKotlin - Set
Minimum SDKtoAPI 28or newer - Finish creating the project
If you already created the project, open it instead.
Open your Android app project in Android Studio.
Step 2 — Add repositories
In your project-level settings.gradle.kts, make sure the app can resolve AndroidX and SmartSpectra artifacts:
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
// Required only for versions ending in -SNAPSHOT.
maven {
url = uri("https://central.sonatype.com/repository/maven-snapshots/")
mavenContent {
snapshotsOnly()
}
}
}
}Step 3 — Add app dependencies
In app/build.gradle.kts, keep the dependencies generated by the Empty Activity template and add only these lines to the existing dependencies block:
dependencies {
implementation("androidx.camera:camera-view:1.6.0")
// Replace <stableSdkVersion> with the value from android/samples/version.properties.
implementation("com.presagetech:smartspectra:<stableSdkVersion>")
// For release-candidate snapshots, use this instead:
// implementation("com.presagetech:smartspectra:<rcSdkVersion>-SNAPSHOT")
}Version values:
- For stable releases, replace
<stableSdkVersion>with the value fromandroid/samples/version.properties. - For release-candidate snapshots, replace
<rcSdkVersion>with the value fromandroid/samples/version.propertiesand keep the-SNAPSHOTsuffix. - You can also browse stable releases on Maven Central.
Manual check:
- Gradle sync succeeds
PreviewView,SmartSpectraSdk, andMetricTypeimports resolve
Step 4 — Replace MainActivity.kt
In Android Studio:
- Open
app/src/main/java/com/example/coolvitals/MainActivity.kt - Delete everything in the file
- Paste the full file below
- Replace
YOUR_API_KEYwith your real API key
Paste this entire file:
package com.example.coolvitals
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.content.res.ColorStateList
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.Path
import android.graphics.drawable.GradientDrawable
import android.os.Bundle
import android.view.Gravity
import android.view.View
import android.view.ViewGroup
import android.widget.Button
import android.widget.FrameLayout
import android.widget.LinearLayout
import android.widget.TextView
import androidx.activity.ComponentActivity
import androidx.activity.result.contract.ActivityResultContracts
import androidx.camera.view.PreviewView
import androidx.core.content.ContextCompat
import androidx.lifecycle.lifecycleScope
import com.presagetech.smartspectra.CameraPosition
import com.presagetech.smartspectra.ProcessingStatus
import com.presagetech.smartspectra.SmartSpectraConfig
import com.presagetech.smartspectra.SmartSpectraSdk
import com.presagetech.smartspectra.proto.MetricTypesProto.MetricType
import com.presagetech.smartspectra.proto.MetricsProto.ExpressionType
import kotlin.math.max
import kotlin.math.min
import kotlin.math.roundToInt
import kotlinx.coroutines.launch
class MainActivity : ComponentActivity() {
private companion object {
const val API_KEY = "YOUR_API_KEY"
val TEXT_PRIMARY = Color.WHITE
val TEXT_MUTED = 0x80FFFFFF.toInt()
val CARD_OVERLAY = 0xE61A2233.toInt()
val CORAL = 0xFFFF6B6B.toInt()
val TEAL = 0xFF4FCCC4.toInt()
val VIOLET = 0xFFA58CF9.toInt()
val MINT = 0xFF6EE7B7.toInt()
val BLUE = 0xFF60A5FA.toInt()
val AMBER = 0xFFFBBF24.toInt()
}
private val sdk by lazy { SmartSpectraSdk.shared }
private lateinit var heartRateLabel: TextView
private lateinit var expressionLabel: TextView
private lateinit var breathingRateLabel: TextView
private lateinit var hrvLabel: TextView
private lateinit var chestGraphView: SignalGraphView
private lateinit var abdomenGraphView: SignalGraphView
private lateinit var bloodPressureGraphView: SignalGraphView
private lateinit var statusLabel: TextView
private lateinit var validationLabel: TextView
private lateinit var toggleButton: Button
private var latestChestTimestamp: Long = Long.MIN_VALUE
private var latestAbdomenTimestamp: Long = Long.MIN_VALUE
private var latestPressureTimestamp: Long = Long.MIN_VALUE
private val cameraPermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission(),
) { granted ->
if (granted) {
startProcessing()
} else {
statusLabel.text = "Status: Camera required"
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
sdk.config.apiKey = API_KEY
sdk.config.imageOutputEnabled = false
sdk.config.cameraPosition = CameraPosition.FRONT
sdk.config.requestedMetrics =
SmartSpectraConfig.breathingMetrics +
SmartSpectraConfig.cardioMetrics +
listOf(MetricType.EXPRESSIONS)
buildUi()
bindSdk()
resetMeasurementUi()
statusLabel.text = "Status: Idle"
}
override fun onPause() {
super.onPause()
lifecycleScope.launch {
when (sdk.processingStatus.value) {
ProcessingStatus.RUNNING,
ProcessingStatus.STARTING,
ProcessingStatus.STOPPING,
-> runCatching { sdk.stop() }
else -> Unit
}
}
}
private fun bindSdk() {
sdk.processingStatus.observe(this) { updateProcessingStatus(it) }
sdk.validationStatus.observe(this) { status ->
validationLabel.text = "Validation: ${status?.code?.name?.replace('_', ' ') ?: "--"}"
}
sdk.error.observe(this) { error ->
if (error != null) {
statusLabel.text = "Error: ${error.message ?: "Unknown"}"
}
}
sdk.metrics.observe(this) { metrics ->
if (metrics == null) return@observe
if (metrics.hasCardio()) {
val pulse = metrics.cardio.pulseRateList
.lastOrNull { it.timestamp > 0 }
?.value
?.roundToInt()
if (pulse != null) {
heartRateLabel.text = "$pulse bpm"
}
metrics.cardio.arterialPressureTraceList.forEach { sample ->
if (sample.timestamp > latestPressureTimestamp) {
latestPressureTimestamp = sample.timestamp
bloodPressureGraphView.appendValue(sample.value)
}
}
metrics.cardio.hrvList.lastOrNull()?.rmssd?.let { rmssd ->
if (rmssd > 0) {
hrvLabel.text = "${(rmssd * 10).roundToInt() / 10.0} ms"
}
}
}
if (metrics.hasBreathing()) {
if (metrics.breathing.rateCount > 0) {
val breathingRate = metrics.breathing.rateList.last().value.roundToInt()
breathingRateLabel.text = "$breathingRate bpm"
}
metrics.breathing.upperTraceList.forEach { sample ->
if (sample.timestamp > latestChestTimestamp) {
latestChestTimestamp = sample.timestamp
chestGraphView.appendValue(sample.value)
}
}
metrics.breathing.lowerTraceList.forEach { sample ->
if (sample.timestamp > latestAbdomenTimestamp) {
latestAbdomenTimestamp = sample.timestamp
abdomenGraphView.appendValue(sample.value)
}
}
}
if (metrics.hasFace()) {
val expression = metrics.face.expressionList.lastOrNull() ?: return@observe
val topScore = expression.scoresList
.filter { it.confidence > 0f }
.maxByOrNull { it.confidence } ?: return@observe
val expressionName = topScore.type.expressionName() ?: return@observe
expressionLabel.text = "%-8.8s %3d%%".format(expressionName, topScore.confidence.roundToInt())
}
}
}
private fun buildUi() {
val root = FrameLayout(this).apply {
background = GradientDrawable(
GradientDrawable.Orientation.TOP_BOTTOM,
intArrayOf(0xFF0B1020.toInt(), 0xFF05070C.toInt()),
)
}
val previewView = PreviewView(this).apply {
contentDescription = "SmartSpectra preview output"
setBackgroundColor(Color.BLACK)
scaleType = PreviewView.ScaleType.FILL_CENTER
implementationMode = PreviewView.ImplementationMode.COMPATIBLE
}
sdk.config.previewSurfaceProvider = previewView.surfaceProvider
val previewParams = FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
dp(465),
Gravity.TOP,
).apply { topMargin = dp(33) }
root.addView(previewView, previewParams)
root.addView(
View(this).apply {
background = GradientDrawable(
GradientDrawable.Orientation.TOP_BOTTOM,
intArrayOf(0x33000000, 0x00000000, 0xCC05070C.toInt()),
)
},
FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
dp(465),
Gravity.TOP,
).apply { topMargin = dp(33) },
)
val topPanel = LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
setPadding(dp(14), dp(33), dp(14), 0)
}
root.addView(
topPanel,
FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT,
Gravity.TOP,
),
)
val topRow = horizontalRow()
statusLabel = statusPill("Status", "Idle", CORAL)
validationLabel = statusPill("Validation", "--", AMBER)
toggleButton = Button(this).apply {
text = "Start"
setAllCaps(false)
setTextColor(Color.BLACK)
backgroundTintList = ColorStateList.valueOf(Color.WHITE)
setOnClickListener { toggleProcessing() }
}
topRow.addView(statusLabel, weightedParams(endMargin = dp(8)))
topRow.addView(validationLabel, weightedParams(endMargin = dp(8)))
topRow.addView(toggleButton, LinearLayout.LayoutParams(dp(92), dp(42)))
topPanel.addView(topRow)
val panel = LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
setPadding(dp(14), 0, dp(14), 0)
}
root.addView(
panel,
FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT,
Gravity.BOTTOM,
).apply { bottomMargin = dp(60) },
)
heartRateLabel = textView(sizeSp = 23f, color = CORAL, bold = true)
breathingRateLabel = textView(sizeSp = 23f, color = TEAL, bold = true)
val rateRow = horizontalRow()
rateRow.addView(metricCard("Pulse Rate", heartRateLabel, CORAL), weightedParams(endMargin = dp(8)))
rateRow.addView(metricCard("Breathing Rate", breathingRateLabel, TEAL), weightedParams())
panel.addView(rateRow, matchWrapBottomMargin(dp(10)))
hrvLabel = textView(sizeSp = 23f, color = TEXT_PRIMARY, bold = true)
expressionLabel = textView(sizeSp = 20f, color = TEXT_PRIMARY, bold = true).apply {
typeface = android.graphics.Typeface.create(android.graphics.Typeface.MONOSPACE, android.graphics.Typeface.BOLD)
includeFontPadding = false
}
val summaryRow = horizontalRow()
summaryRow.addView(metricCard("HRV RMSSD", hrvLabel, MINT), weightedParams(endMargin = dp(8)))
summaryRow.addView(metricCard("Expression", expressionLabel, AMBER), weightedParams())
panel.addView(summaryRow, matchWrapBottomMargin(dp(10)))
bloodPressureGraphView = SignalGraphView(this, VIOLET)
panel.addView(waveformCard("Arterial Pressure", bloodPressureGraphView), matchWrapBottomMargin(dp(10)))
chestGraphView = SignalGraphView(this, TEAL)
abdomenGraphView = SignalGraphView(this, BLUE)
val breathingRow = horizontalRow()
breathingRow.addView(waveformCard("Chest Waveform", chestGraphView), weightedParams(endMargin = dp(8)))
breathingRow.addView(waveformCard("Abdomen Waveform", abdomenGraphView), weightedParams())
panel.addView(breathingRow, matchHeight(dp(126)))
setContentView(root)
}
private fun toggleProcessing() {
when (sdk.processingStatus.value) {
ProcessingStatus.RUNNING -> lifecycleScope.launch { runCatching { sdk.stop() } }
ProcessingStatus.STARTING, ProcessingStatus.STOPPING -> Unit
else -> startProcessing()
}
}
private fun startProcessing() {
if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA)
!= PackageManager.PERMISSION_GRANTED
) {
cameraPermissionLauncher.launch(Manifest.permission.CAMERA)
return
}
lifecycleScope.launch {
resetMeasurementUi()
runCatching { sdk.start() }
.onFailure {
statusLabel.text = "Error: ${it.message ?: "Unknown"}"
}
}
}
private fun updateProcessingStatus(status: ProcessingStatus?) {
when (status) {
ProcessingStatus.IDLE -> {
statusLabel.text = "Status: Idle"
toggleButton.text = "Start"
toggleButton.isEnabled = true
}
ProcessingStatus.STARTING -> {
statusLabel.text = "Status: Starting"
toggleButton.text = "Starting..."
toggleButton.isEnabled = false
}
ProcessingStatus.RUNNING -> {
statusLabel.text = "Status: Running"
toggleButton.text = "Stop"
toggleButton.isEnabled = true
}
ProcessingStatus.STOPPING -> {
statusLabel.text = "Status: Stopping"
toggleButton.text = "Stopping..."
toggleButton.isEnabled = false
}
ProcessingStatus.ERROR -> {
toggleButton.text = "Start"
toggleButton.isEnabled = true
}
null -> Unit
}
}
private fun resetMeasurementUi() {
latestChestTimestamp = Long.MIN_VALUE
latestAbdomenTimestamp = Long.MIN_VALUE
latestPressureTimestamp = Long.MIN_VALUE
chestGraphView.reset()
abdomenGraphView.reset()
bloodPressureGraphView.reset()
heartRateLabel.text = "-- bpm"
expressionLabel.text = "--"
breathingRateLabel.text = "-- bpm"
hrvLabel.text = "-- ms"
}
private fun card(buildChildren: LinearLayout.() -> Unit): LinearLayout =
LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
background = GradientDrawable().apply {
setColor(CARD_OVERLAY)
cornerRadius = dp(20).toFloat()
}
setPadding(dp(12), dp(8), dp(12), dp(8))
buildChildren()
}
private fun metricCard(title: String, value: TextView, accent: Int): LinearLayout =
card {
addView(textView(title, sizeSp = 12f, color = TEXT_MUTED, bold = true))
addView(value, matchWrapTopMargin(dp(4)))
}.apply {
background = GradientDrawable().apply {
setColor(CARD_OVERLAY)
setStroke(dp(1), colorWithAlpha(accent, 70))
cornerRadius = dp(20).toFloat()
}
}
private fun waveformCard(title: String, graphView: SignalGraphView): LinearLayout =
card {
addView(textView(title, sizeSp = 12f, color = TEXT_PRIMARY, bold = true))
addView(graphView, matchHeight(dp(78)).apply { topMargin = dp(6) })
}
private fun statusPill(title: String, value: String, accent: Int): TextView =
textView("$title: $value", sizeSp = 12f, color = TEXT_PRIMARY, bold = true).apply {
gravity = Gravity.CENTER_VERTICAL
setPadding(dp(10), 0, dp(10), 0)
background = GradientDrawable().apply {
setColor(0x26FFFFFF)
setStroke(dp(1), colorWithAlpha(accent, 90))
cornerRadius = dp(18).toFloat()
}
}
private fun horizontalRow(): LinearLayout =
LinearLayout(this).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
}
private fun textView(
text: String = "",
sizeSp: Float,
color: Int,
bold: Boolean = false,
): TextView = TextView(this).apply {
this.text = text
textSize = sizeSp
setTextColor(color)
if (bold) typeface = android.graphics.Typeface.DEFAULT_BOLD
}
private fun matchWrapBottomMargin(bottomMargin: Int): LinearLayout.LayoutParams =
LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT,
).apply { this.bottomMargin = bottomMargin }
private fun matchHeight(height: Int): LinearLayout.LayoutParams =
LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
height,
)
private fun matchWrapTopMargin(topMargin: Int): LinearLayout.LayoutParams =
LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT,
).apply { this.topMargin = topMargin }
private fun weightedParams(endMargin: Int = 0): LinearLayout.LayoutParams =
LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f).apply {
marginEnd = endMargin
}
private fun dp(value: Int): Int = (value * resources.displayMetrics.density).roundToInt()
private fun colorWithAlpha(color: Int, alpha: Int): Int =
Color.argb(alpha, Color.red(color), Color.green(color), Color.blue(color))
private fun ExpressionType.expressionName(): String? =
when (this) {
ExpressionType.ANGRY -> "Angry"
ExpressionType.CONTEMPT -> "Contempt"
ExpressionType.DISGUST -> "Disgust"
ExpressionType.FEAR -> "Fear"
ExpressionType.HAPPY -> "Happy"
ExpressionType.NEUTRAL -> "Neutral"
ExpressionType.SAD -> "Sad"
ExpressionType.SURPRISE -> "Surprise"
else -> null
}
}
private class SignalGraphView(
context: Context,
private val graphColor: Int,
) : View(context) {
private companion object {
const val GRID_ALPHA = 12
const val LINE_ALPHA = 170
}
private val samples = ArrayDeque<Float>()
private val maxPoints = 200
private val inset = 10f
private val linePath = Path()
private val gridPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.argb(GRID_ALPHA, 255, 255, 255)
strokeWidth = 1f
style = Paint.Style.STROKE
}
private val linePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = colorWithAlpha(LINE_ALPHA)
strokeWidth = 3.5f
style = Paint.Style.STROKE
strokeCap = Paint.Cap.ROUND
strokeJoin = Paint.Join.ROUND
}
fun appendValue(value: Float) {
samples.addLast(value)
while (samples.size > maxPoints) {
samples.removeFirst()
}
invalidate()
}
fun reset() {
samples.clear()
invalidate()
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
val sampleCount = samples.size
if (sampleCount < 2) return
val drawLeft = inset
val drawTop = inset
val drawRight = width - inset
val drawBottom = height - inset
val drawWidth = drawRight - drawLeft
val drawHeight = drawBottom - drawTop
for (gridLine in 1..3) {
val y = drawTop + drawHeight * (1f - gridLine / 4f)
canvas.drawLine(drawLeft, y, drawRight, y, gridPaint)
}
var minValue = Float.POSITIVE_INFINITY
var maxValue = Float.NEGATIVE_INFINITY
samples.forEach {
minValue = min(minValue, it)
maxValue = max(maxValue, it)
}
val range = if (maxValue - minValue == 0f) 1f else maxValue - minValue
linePath.reset()
samples.forEachIndexed { index, value ->
val x = drawLeft + drawWidth * index / (sampleCount - 1)
val normalized = (value - minValue) / range
val y = drawBottom - normalized * drawHeight
if (index == 0) {
linePath.moveTo(x, y)
} else {
linePath.lineTo(x, y)
}
}
canvas.drawPath(linePath, linePaint)
}
private fun colorWithAlpha(alpha: Int): Int =
Color.argb(alpha, Color.red(graphColor), Color.green(graphColor), Color.blue(graphColor))
}Expected API key check
The first measurement should start after the camera permission prompt is granted. If startup fails with an authentication error, verify that API_KEY is valid and authorized for this app.