mirror of
https://github.com/MrUnknownDE/wearos-heartmonitor-wss.git
synced 2026-04-28 11:13:46 +02:00
basic component
This commit is contained in:
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
|
||||
)
|
||||
Reference in New Issue
Block a user