mirror of
https://github.com/MrUnknownDE/wearos-heartmonitor-wss.git
synced 2026-04-06 00:31:59 +02:00
basic component
This commit is contained in:
24
README.md
24
README.md
@@ -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
69
app/build.gradle.kts
Normal 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")
|
||||
}
|
||||
35
app/src/main/AndroidManifest.xml
Normal file
35
app/src/main/AndroidManifest.xml
Normal 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>
|
||||
50
app/src/main/java/com/mrunk/wearhr/Health.kt
Normal file
50
app/src/main/java/com/mrunk/wearhr/Health.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
69
app/src/main/java/com/mrunk/wearhr/HrStreamService.kt
Normal file
69
app/src/main/java/com/mrunk/wearhr/HrStreamService.kt
Normal 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))
|
||||
}
|
||||
}
|
||||
57
app/src/main/java/com/mrunk/wearhr/MainActivity.kt
Normal file
57
app/src/main/java/com/mrunk/wearhr/MainActivity.kt
Normal 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…"
|
||||
}
|
||||
}
|
||||
17
app/src/main/java/com/mrunk/wearhr/Prefs.kt
Normal file
17
app/src/main/java/com/mrunk/wearhr/Prefs.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
55
app/src/main/java/com/mrunk/wearhr/WsClient.kt
Normal file
55
app/src/main/java/com/mrunk/wearhr/WsClient.kt
Normal 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
|
||||
)
|
||||
4
app/src/main/res/drawable/ic_heart.xml
Normal file
4
app/src/main/res/drawable/ic_heart.xml
Normal 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>
|
||||
65
app/src/main/res/layout/activity_main.xml
Normal file
65
app/src/main/res/layout/activity_main.xml
Normal 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>
|
||||
10
app/src/main/res/values/strings.xml
Normal file
10
app/src/main/res/values/strings.xml
Normal 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>
|
||||
4
app/src/main/res/xml/network_security_config.xml
Normal file
4
app/src/main/res/xml/network_security_config.xml
Normal 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
4
build.gradle.kts
Normal 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
3
gradle.properties
Normal 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
2
settings.gradle.kts
Normal file
@@ -0,0 +1,2 @@
|
||||
rootProject.name = "wearos-heartmonitor-wss"
|
||||
include(":app")
|
||||
Reference in New Issue
Block a user