From e665b9746a37bd9847181c3b9e0c50bd51515c5e Mon Sep 17 00:00:00 2001 From: MrUnknownDE Date: Wed, 13 Aug 2025 13:20:19 +0200 Subject: [PATCH] basic component --- README.md | 24 ++++++- app/build.gradle.kts | 69 +++++++++++++++++++ app/src/main/AndroidManifest.xml | 35 ++++++++++ app/src/main/java/com/mrunk/wearhr/Health.kt | 50 ++++++++++++++ .../java/com/mrunk/wearhr/HrStreamService.kt | 69 +++++++++++++++++++ .../java/com/mrunk/wearhr/MainActivity.kt | 57 +++++++++++++++ app/src/main/java/com/mrunk/wearhr/Prefs.kt | 17 +++++ .../main/java/com/mrunk/wearhr/WsClient.kt | 55 +++++++++++++++ app/src/main/res/drawable/ic_heart.xml | 4 ++ app/src/main/res/layout/activity_main.xml | 65 +++++++++++++++++ app/src/main/res/values/strings.xml | 10 +++ .../main/res/xml/network_security_config.xml | 4 ++ build.gradle.kts | 4 ++ gradle.properties | 3 + settings.gradle.kts | 2 + 15 files changed, 467 insertions(+), 1 deletion(-) create mode 100644 app/build.gradle.kts create mode 100644 app/src/main/AndroidManifest.xml create mode 100644 app/src/main/java/com/mrunk/wearhr/Health.kt create mode 100644 app/src/main/java/com/mrunk/wearhr/HrStreamService.kt create mode 100644 app/src/main/java/com/mrunk/wearhr/MainActivity.kt create mode 100644 app/src/main/java/com/mrunk/wearhr/Prefs.kt create mode 100644 app/src/main/java/com/mrunk/wearhr/WsClient.kt create mode 100644 app/src/main/res/drawable/ic_heart.xml create mode 100644 app/src/main/res/layout/activity_main.xml create mode 100644 app/src/main/res/values/strings.xml create mode 100644 app/src/main/res/xml/network_security_config.xml create mode 100644 build.gradle.kts create mode 100644 gradle.properties create mode 100644 settings.gradle.kts diff --git a/README.md b/README.md index 8ee4c17..566d11c 100644 --- a/README.md +++ b/README.md @@ -1 +1,23 @@ -# wearos-heartmonitor-wss \ No newline at end of file +# 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 ` +- 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 \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..8a2a33a --- /dev/null +++ b/app/build.gradle.kts @@ -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") +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..2e570ed --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/java/com/mrunk/wearhr/Health.kt b/app/src/main/java/com/mrunk/wearhr/Health.kt new file mode 100644 index 0000000..a200c89 --- /dev/null +++ b/app/src/main/java/com/mrunk/wearhr/Health.kt @@ -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) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/mrunk/wearhr/HrStreamService.kt b/app/src/main/java/com/mrunk/wearhr/HrStreamService.kt new file mode 100644 index 0000000..58b6a8a --- /dev/null +++ b/app/src/main/java/com/mrunk/wearhr/HrStreamService.kt @@ -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)) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/mrunk/wearhr/MainActivity.kt b/app/src/main/java/com/mrunk/wearhr/MainActivity.kt new file mode 100644 index 0000000..aab0fcf --- /dev/null +++ b/app/src/main/java/com/mrunk/wearhr/MainActivity.kt @@ -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(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(R.id.statusText).text = "Starting…" + } +} \ No newline at end of file diff --git a/app/src/main/java/com/mrunk/wearhr/Prefs.kt b/app/src/main/java/com/mrunk/wearhr/Prefs.kt new file mode 100644 index 0000000..7999da8 --- /dev/null +++ b/app/src/main/java/com/mrunk/wearhr/Prefs.kt @@ -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() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/mrunk/wearhr/WsClient.kt b/app/src/main/java/com/mrunk/wearhr/WsClient.kt new file mode 100644 index 0000000..9c1fe48 --- /dev/null +++ b/app/src/main/java/com/mrunk/wearhr/WsClient.kt @@ -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 +) \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_heart.xml b/app/src/main/res/drawable/ic_heart.xml new file mode 100644 index 0000000..f164ab1 --- /dev/null +++ b/app/src/main/res/drawable/ic_heart.xml @@ -0,0 +1,4 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..6a33f72 --- /dev/null +++ b/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,65 @@ + + + + + + + + + +