basic component

This commit is contained in:
2025-08-13 13:20:19 +02:00
parent 851bc1a010
commit e665b9746a
15 changed files with 467 additions and 1 deletions

View File

@@ -1 +1,23 @@
# wearos-heartmonitor-wss
# wearos-heartmonitor-wss
Wear OS app that streams heart rate in real time over secure WebSocket.
## Build
- Android Studio Jellyfish/Koala, SDK 34
- Open the project, select **app** run config, deploy to Pixel Watch 3 (Wear OS 4)
## Configure
On the watch:
1. Open the app
2. Set **Server URL**: `wss://watch.puls.mrunk.de/ws`
3. Paste **JWT** token ("Bearer" not needed)
4. Tap **Save****Start**
## Server expectations
- Accept `Authorization: Bearer <JWT>`
- Receive JSON like: `{ "type":"hr", "bpm":92, "ts":"2025-08-13T10:15:30Z", "source":"wearos-healthservices" }`
## Notes
- App starts a Foreground Service to keep streaming alive
- Uses Health Services Exercise API for stable, low-latency HR
- No data stored on device; only in-flight streaming

69
app/build.gradle.kts Normal file
View File

@@ -0,0 +1,69 @@
import com.android.build.api.dsl.ApplicationDefaultConfig
plugins {
id("com.android.application")
kotlin("android")
}
android {
namespace = "com.mrunk.wearhr"
compileSdk = 34
defaultConfig {
applicationId = "com.mrunk.wearhr"
minSdk = 30 // Wear OS 3+
targetSdk = 34
versionCode = 1
versionName = "0.1.0"
// Allow cleartext OFF by default; we use WSS.
resourceConfigurations += listOf("en", "de")
}
buildTypes {
release {
isMinifyEnabled = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
debug {
isMinifyEnabled = false
}
}
packaging.resources.excludes += "META-INF/{AL2.0,LGPL2.1}"
buildFeatures { viewBinding = true }
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions { jvmTarget = "17" }
// Network security: enforce TLS (can be relaxed for local testing)
defaultConfig {
manifestPlaceholders["networkSecurityConfig"] = "@xml/network_security_config"
}
}
dependencies {
implementation("androidx.core:core-ktx:1.13.1")
implementation("androidx.appcompat:appcompat:1.7.0")
implementation("androidx.activity:activity-ktx:1.9.2")
implementation("androidx.constraintlayout:constraintlayout:2.1.4")
// Wear OS UI helpers (optional, for round screens)
implementation("androidx.wear:wear:1.3.0")
// Health Services client (Wear OS)
implementation("androidx.health:health-services-client:1.1.0")
// OkHttp WebSocket
implementation("com.squareup.okhttp3:okhttp:4.12.0")
// JSON
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3")
}

View File

@@ -0,0 +1,35 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-feature android:name="android.hardware.type.watch"/>
<uses-feature android:name="android.hardware.sensor.heartrate" android:required="false"/>
<uses-permission android:name="android.permission.BODY_SENSORS"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_HEALTH"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.WAKE_LOCK"/>
<uses-permission android:name="android.permission.INTERNET"/>
<application
android:label="Wear HR"
android:usesCleartextTraffic="false"
android:allowBackup="false"
android:networkSecurityConfig="@xml/network_security_config">
<service
android:name=".HrStreamService"
android:exported="false"
android:foregroundServiceType="healthData">
</service>
<activity android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
</application>
</manifest>

View File

@@ -0,0 +1,50 @@
package com.mrunk.wearhr
import android.content.Context
import android.util.Log
import androidx.health.services.client.ExerciseClient
import androidx.health.services.client.HealthServices
import androidx.health.services.client.data.DataType
import androidx.health.services.client.data.ExerciseCapabilities
import androidx.health.services.client.data.ExerciseConfig
import androidx.health.services.client.data.ExerciseGoal
import androidx.health.services.client.data.ExerciseType
import androidx.health.services.client.data.HeartRateAccuracy
import androidx.health.services.client.data.IntervalGoal
import androidx.health.services.client.data.SetGoal
import androidx.health.services.client.event.ExerciseUpdate
import androidx.health.services.client.event.ExerciseUpdateListener
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
class Health(private val ctx: Context, private val onHr: (Int) -> Unit) : ExerciseUpdateListener {
private val scope = CoroutineScope(Dispatchers.Default + Job())
private val exerciseClient: ExerciseClient = HealthServices.getClient(ctx).exerciseClient
suspend fun start() {
val caps: ExerciseCapabilities = exerciseClient.getCapabilitiesAsync().await()
if (!caps.supportedExerciseTypes.contains(ExerciseType.OTHER_WORKOUT)) {
Log.w("Health", "Exercise OTHER_WORKOUT not supported; trying without exercise")
}
val config = ExerciseConfig(
exerciseType = ExerciseType.OTHER_WORKOUT,
dataTypes = setOf(DataType.HEART_RATE_BPM)
)
exerciseClient.setUpdateListener(this)
exerciseClient.startExerciseAsync(config).await()
}
suspend fun stop() {
exerciseClient.pauseExerciseAsync().await()
exerciseClient.endExerciseAsync().await()
exerciseClient.clearUpdateListener()
}
override fun onExerciseUpdateReceived(update: ExerciseUpdate) {
val hr = update.latestMetrics[DataType.HEART_RATE_BPM]
val bpm = hr?.value?.toInt()
if (bpm != null && bpm > 0) onHr(bpm)
}
}

View File

@@ -0,0 +1,69 @@
package com.mrunk.wearhr
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.Service
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.IBinder
import androidx.core.app.NotificationCompat
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
class HrStreamService : Service() {
private val scope = CoroutineScope(Dispatchers.Default + Job())
private var ws: WsClient? = null
private var health: Health? = null
override fun onCreate() {
super.onCreate()
createNotifChannel()
startForeground(1, notif("Connecting…"))
val url = Prefs.getUrl(this)
val jwt = Prefs.getJwt(this)
ws = WsClient(
url, jwt,
onOpen = {
updateNotif("Streaming HR…")
scope.launch {
health = Health(this@HrStreamService) { bpm -> ws?.sendHr(bpm) }
try { health?.start() } catch (_: Throwable) { updateNotif("HR start failed") }
}
},
onClose = { _, _ -> updateNotif("Disconnected") },
onError = { updateNotif("WS error: ${'$'}{it.message}") }
)
ws?.connect()
}
override fun onDestroy() {
super.onDestroy()
scope.launch { runCatching { health?.stop() } }
ws?.close()
}
override fun onBind(intent: Intent?): IBinder? = null
private fun createNotifChannel() {
if (Build.VERSION.SDK_INT >= 26) {
val nm = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
nm.createNotificationChannel(NotificationChannel("hr", "HR Streaming", NotificationManager.IMPORTANCE_LOW))
}
}
private fun notif(text: String): Notification = NotificationCompat.Builder(this, "hr")
.setSmallIcon(R.drawable.ic_heart)
.setContentTitle("Wear HR")
.setContentText(text)
.setOngoing(true)
.build()
private fun updateNotif(text: String) {
val nm = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
nm.notify(1, notif(text))
}
}

View File

@@ -0,0 +1,57 @@
package com.mrunk.wearhr
import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Bundle
import android.widget.Button
import android.widget.EditText
import android.widget.TextView
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import com.mrunk.wearhr.databinding.ActivityMainBinding
class MainActivity : AppCompatActivity() {
private lateinit var binding: ActivityMainBinding
private val requestPerm = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted ->
if (!granted) {
findViewById<TextView>(R.id.statusText).text = "Permission denied"
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
binding.inputUrl.setText(Prefs.getUrl(this))
binding.inputToken.setText(Prefs.getJwt(this))
binding.btnSave.setOnClickListener {
Prefs.set(this, binding.inputUrl.text.toString(), binding.inputToken.text.toString())
binding.statusText.text = "Saved"
}
binding.btnStart.setOnClickListener {
ensurePermissionsAndStart()
}
binding.btnStop.setOnClickListener {
stopService(Intent(this, HrStreamService::class.java))
binding.statusText.text = getString(R.string.status_disconnected)
}
}
private fun ensurePermissionsAndStart() {
if (ContextCompat.checkSelfPermission(this, Manifest.permission.BODY_SENSORS) != PackageManager.PERMISSION_GRANTED) {
requestPerm.launch(Manifest.permission.BODY_SENSORS)
return
}
startForegroundService(Intent(this, HrStreamService::class.java))
findViewById<TextView>(R.id.statusText).text = "Starting…"
}
}

View File

@@ -0,0 +1,17 @@
package com.mrunk.wearhr
import android.content.Context
import android.content.SharedPreferences
object Prefs {
private const val FILE = "wearhr"
private const val KEY_URL = "url"
private const val KEY_JWT = "jwt"
fun prefs(ctx: Context): SharedPreferences = ctx.getSharedPreferences(FILE, Context.MODE_PRIVATE)
fun getUrl(ctx: Context) = prefs(ctx).getString(KEY_URL, "") ?: ""
fun getJwt(ctx: Context) = prefs(ctx).getString(KEY_JWT, "") ?: ""
fun set(ctx: Context, url: String, jwt: String) {
prefs(ctx).edit().putString(KEY_URL, url.trim()).putString(KEY_JWT, jwt.trim()).apply()
}
}

View File

@@ -0,0 +1,55 @@
package com.mrunk.wearhr
import android.util.Log
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import okhttp3.*
import okio.ByteString
import java.util.concurrent.TimeUnit
class WsClient(
private val url: String,
private val jwt: String,
private val onOpen: () -> Unit,
private val onClose: (Int, String) -> Unit,
private val onError: (Throwable) -> Unit
) {
private val json = Json { ignoreUnknownKeys = true }
private val client = OkHttpClient.Builder()
.pingInterval(15, TimeUnit.SECONDS)
.readTimeout(0, TimeUnit.MILLISECONDS)
.build()
private var ws: WebSocket? = null
fun connect() {
val req = Request.Builder()
.url(url)
.addHeader("Authorization", "Bearer $jwt")
.build()
ws = client.newWebSocket(req, object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) { onOpen() }
override fun onMessage(webSocket: WebSocket, text: String) { /* ignore */ }
override fun onMessage(webSocket: WebSocket, bytes: ByteString) { /* ignore */ }
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { onClose(code, reason) }
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { onError(t) }
})
}
fun sendHr(bpm: Int) {
val payload = json.encodeToString(HrEvent(bpm = bpm))
ws?.send(payload)
}
fun close() { ws?.close(1000, "bye") }
}
@Serializable
data class HrEvent(
val type: String = "hr",
val bpm: Int,
val ts: String = java.time.Instant.now().toString(),
val source: String = "wearos-healthservices",
val sessionId: String? = null
)

View File

@@ -0,0 +1,4 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#FFFFFF" android:pathData="M12,21.35l-1.45,-1.32C5.4,15.36 2,12.28 2,8.5 2,6 4,4 6.5,4c1.74,0 3.41,0.81 4.5,2.09C12.09,4.81 13.76,4 15.5,4 18,4 20,6 20,8.5c0,3.78 -3.4,6.86 -8.55,11.54L12,21.35z"/>
</vector>

View File

@@ -0,0 +1,65 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="12dp">
<EditText
android:id="@+id/inputUrl"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:hint="@string/server_url"
android:inputType="textUri"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"/>
<EditText
android:id="@+id/inputToken"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:hint="@string/jwt_token"
android:inputType="textPassword"
app:layout_constraintTop_toBottomOf="@id/inputUrl"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"/>
<TextView
android:id="@+id/statusText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/status_disconnected"
app:layout_constraintTop_toBottomOf="@id/inputToken"
app:layout_constraintStart_toStartOf="parent"/>
<Button
android:id="@+id/btnSave"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/save"
app:layout_constraintTop_toBottomOf="@id/inputToken"
app:layout_constraintEnd_toEndOf="parent"/>
<Button
android:id="@+id/btnStart"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@string/start"
app:layout_constraintTop_toBottomOf="@id/statusText"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toStartOf="@id/btnStop"
app:layout_constraintHorizontalWeight="1"/>
<Button
android:id="@+id/btnStop"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@string/stop"
app:layout_constraintTop_toBottomOf="@id/statusText"
app:layout_constraintStart_toEndOf="@id/btnStart"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontalWeight="1"/>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -0,0 +1,10 @@
<resources>
<string name="app_name">Wear HR</string>
<string name="perm_rationale">Die App benötigt Zugriff auf den Herzfrequenzsensor.</string>
<string name="start">Start</string>
<string name="stop">Stop</string>
<string name="save">Save</string>
<string name="server_url">Server URL (wss://...)</string>
<string name="jwt_token">JWT Token</string>
<string name="status_disconnected">Disconnected</string>
</resources>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<base-config cleartextTrafficPermitted="false" />
</network-security-config>

4
build.gradle.kts Normal file
View File

@@ -0,0 +1,4 @@
plugins {
id("com.android.application") version "8.5.0" apply false
kotlin("android") version "1.9.24" apply false
}

3
gradle.properties Normal file
View File

@@ -0,0 +1,3 @@
org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8
android.useAndroidX=true
kotlin.code.style=official

2
settings.gradle.kts Normal file
View File

@@ -0,0 +1,2 @@
rootProject.name = "wearos-heartmonitor-wss"
include(":app")