SmartSpectra SDK
Android

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:

  1. The app Gradle repositories and dependencies
  2. 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

  1. Navigate to the Presage Developer Admin Service Portal
  2. Click Register and fill in your email, password, and other required fields.
  3. Check your email for a confirmation link and follow it to activate your account.

Log In

  1. Go to the Presage Developer Admin Portal Login
  2. Enter your email and password, then click Submit.
  3. 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:

  1. Select FileNewNew Project...
  2. Choose Empty Activity
  3. Set Name to Cool Vitals
  4. Set Package name to com.example.coolvitals
  5. Set Language to Kotlin
  6. Set Minimum SDK to API 28 or newer
  7. 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:

Manual check:

  • Gradle sync succeeds
  • PreviewView, SmartSpectraSdk, and MetricType imports resolve

Step 4 — Replace MainActivity.kt

In Android Studio:

  1. Open app/src/main/java/com/example/coolvitals/MainActivity.kt
  2. Delete everything in the file
  3. Paste the full file below
  4. Replace YOUR_API_KEY with 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.

On this page