Option 2: OAuth
SmartSpectra Android setup using OAuth instead of a hard-coded API key.
Use this if you want to use SmartSpectra OAuth instead of an API key.
Android OAuth is currently documented for Play Store releases. For local development, internal QA, or sideloaded debug builds, use Option 1: API Key instead.
What you will change manually
You will touch exactly these things:
- The app Gradle repositories and dependencies
- The OAuth app registration in the Presage developer portal
- The OAuth XML file at
app/src/main/res/xml/presage_services.xml 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
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 — Get presage_services.xml
This file is not created by Android Studio and is not included automatically when you add the SmartSpectra dependency.
You need to get it from Presage first:
- Sign in to the Presage developer portal:
https://physiology.presagetech.com/ - Open Account → Registered App for OAuth
- Select
Android - Enter your Android App ID. For this quickstart, use
com.example.coolvitals. - Enter the SHA-256 fingerprint for the signing certificate used by the app you will release.
- Click Register App ID
- Download the Android OAuth config file named
presage_services.xml
If you are testing a sandbox-enabled app registration, make sure the app row shows Sandbox Status: Enabled. Click Enable if sandbox is not already enabled.
See Google's Authenticating Your Client guide for Android signing certificate fingerprints.
To get the SHA-256 fingerprint from Gradle, run:
./gradlew signingReport<img src={${process.env.NEXT_PUBLIC_BASE_PATH || ""}/docs/android/SHA256Example.jpeg} alt="Android Studio signing report showing the SHA-256 fingerprint" />
If you cannot find a download for presage_services.xml, stop here. Ask Presage support or your Presage contact for the Android OAuth XML for this app.
Step 5 — Add presage_services.xml to the app
In Android Studio:
- Create
app/src/main/res/xml/if it does not already exist - Put
presage_services.xmlin that directory - Confirm the file is packaged with the app target
The file should contain the OAuth fields provided by Presage, for example:
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:android="http://schemas.android.com/apk/res/android">
<string name="oauth_enabled">true</string>
<string name="client_id">your_client_id</string>
<string name="sub">your_sub</string>
<string name="config_version">1.0</string>
</resources>Step 6 — 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
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 {
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.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 OAuth check
If OAuth is wired correctly, the app should start without setting sdk.config.apiKey. If startup fails with an authentication error, verify that presage_services.xml is present in app/src/main/res/xml/ and that the OAuth app registration matches the installed app.