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

@@ -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
)