feat(components): ActionButton stopAlert + typography type fixes
No files matched your search
@@ -45,7 +45,7 @@ react {
|
||||
|
||||
/* Hermes Commands */
|
||||
// The hermes compiler command to run. By default it is 'hermesc'
|
||||
// hermesCommand = "$rootDir/my-custom-hermesc/bin/hermesc"
|
||||
hermesCommand = "D:/h/node_modules/react-native/sdks/hermesc/win64-bin/hermesc.exe"
|
||||
//
|
||||
// The list of flags to pass to the Hermes compiler. By default is "-O", "-output-source-map"
|
||||
// hermesFlags = ["-O", "-output-source-map"]
|
||||
@@ -77,14 +77,23 @@ android {
|
||||
buildToolsVersion rootProject.ext.buildToolsVersion
|
||||
compileSdk rootProject.ext.compileSdkVersion
|
||||
|
||||
namespace "com.fightratern"
|
||||
namespace "com.healthtracking.fightrate"
|
||||
defaultConfig {
|
||||
applicationId "com.fightratern"
|
||||
applicationId "com.healthtracking.fightrate"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 1
|
||||
versionName "1.0"
|
||||
}
|
||||
splits {
|
||||
abi {
|
||||
enable true
|
||||
reset()
|
||||
include 'arm64-v8a'
|
||||
universalApk false
|
||||
}
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
debug {
|
||||
storeFile file('debug.keystore')
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
||||
|
||||
<uses-feature android:name="android.hardware.bluetooth_le" android:required="true" />
|
||||
|
||||
<application
|
||||
android:name=".MainApplication"
|
||||
@@ -23,5 +34,10 @@
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<service
|
||||
android:name=".capture.FightRateCaptureService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="connectedDevice" />
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.fightratern
|
||||
package com.healthtracking.fightrate
|
||||
|
||||
import com.facebook.react.ReactActivity
|
||||
import com.facebook.react.ReactActivityDelegate
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.fightratern
|
||||
package com.healthtracking.fightrate
|
||||
|
||||
import android.app.Application
|
||||
import com.facebook.react.PackageList
|
||||
@@ -6,6 +6,7 @@ import com.facebook.react.ReactApplication
|
||||
import com.facebook.react.ReactHost
|
||||
import com.facebook.react.ReactNativeApplicationEntryPoint.loadReactNative
|
||||
import com.facebook.react.defaults.DefaultReactHost.getDefaultReactHost
|
||||
import com.healthtracking.fightrate.capture.FightRateNativePackage
|
||||
|
||||
class MainApplication : Application(), ReactApplication {
|
||||
|
||||
@@ -14,9 +15,8 @@ class MainApplication : Application(), ReactApplication {
|
||||
context = applicationContext,
|
||||
packageList =
|
||||
PackageList(this).packages.apply {
|
||||
// Packages that cannot be autolinked yet can be added manually here, for example:
|
||||
// add(MyReactNativePackage())
|
||||
},
|
||||
add(FightRateNativePackage())
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,358 @@
|
||||
package com.healthtracking.fightrate.capture
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.PowerManager
|
||||
import android.provider.Settings
|
||||
import com.facebook.react.bridge.Arguments
|
||||
import com.facebook.react.bridge.Promise
|
||||
import com.facebook.react.bridge.ReactApplicationContext
|
||||
import com.facebook.react.bridge.ReactContextBaseJavaModule
|
||||
import com.facebook.react.bridge.ReactMethod
|
||||
import com.facebook.react.bridge.WritableArray
|
||||
import com.facebook.react.bridge.WritableMap
|
||||
import com.facebook.react.modules.core.DeviceEventManagerModule
|
||||
|
||||
class FightRateCaptureModule(private val reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) {
|
||||
private val store by lazy { FightRateCaptureStore(reactContext.applicationContext) }
|
||||
private var receiverRegistered = false
|
||||
|
||||
private val receiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
when (intent.action) {
|
||||
FightRateCaptureService.ACTION_SAMPLE -> emit(EVENT_SAMPLE, sampleFromIntent(intent))
|
||||
FightRateCaptureService.ACTION_STATUS -> emit(EVENT_STATUS, statusFromIntent(intent))
|
||||
FightRateCaptureService.ACTION_DEVICES -> emit(EVENT_DEVICES, devicesFromIntent(intent))
|
||||
FightRateCaptureService.ACTION_METADATA -> emit(EVENT_METADATA, metadataFromIntent(intent))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun getName(): String = NAME
|
||||
|
||||
override fun initialize() {
|
||||
super.initialize()
|
||||
registerReceiverIfNeeded()
|
||||
restoreMonitoringIfNeeded()
|
||||
}
|
||||
|
||||
override fun invalidate() {
|
||||
unregisterReceiverIfNeeded()
|
||||
super.invalidate()
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
fun addListener(eventName: String) {
|
||||
registerReceiverIfNeeded()
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
fun removeListeners(count: Int) = Unit
|
||||
|
||||
@ReactMethod
|
||||
fun startMonitoring(deviceId: String?, deviceName: String?, promise: Promise) {
|
||||
runCatching {
|
||||
FightRateCaptureService.startMonitoring(reactContext.applicationContext, deviceId, deviceName)
|
||||
promise.resolve(snapshotMap())
|
||||
}.onFailure { promise.reject("FIGHTRATE_MONITOR_START_FAILED", it) }
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
fun startCapture(deviceId: String?, deviceName: String?, promise: Promise) {
|
||||
startWorkout(deviceId, deviceName, promise)
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
fun startWorkout(deviceId: String?, deviceName: String?, promise: Promise) {
|
||||
runCatching {
|
||||
FightRateCaptureService.startWorkout(reactContext.applicationContext, deviceId, deviceName)
|
||||
promise.resolve(snapshotMap())
|
||||
}.onFailure { promise.reject("FIGHTRATE_START_FAILED", it) }
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
fun connectToDevice(deviceId: String, deviceName: String?, promise: Promise) {
|
||||
runCatching {
|
||||
FightRateCaptureService.connect(reactContext.applicationContext, deviceId, deviceName)
|
||||
promise.resolve(snapshotMap())
|
||||
}.onFailure { promise.reject("FIGHTRATE_CONNECT_FAILED", it) }
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
fun stopCapture(promise: Promise) {
|
||||
stopWorkout(promise)
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
fun stopWorkout(promise: Promise) {
|
||||
runCatching {
|
||||
FightRateCaptureService.stopWorkout(reactContext.applicationContext)
|
||||
promise.resolve(snapshotMap())
|
||||
}.onFailure { promise.reject("FIGHTRATE_STOP_FAILED", it) }
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
fun stopMonitoring(promise: Promise) {
|
||||
runCatching {
|
||||
FightRateCaptureService.stop(reactContext.applicationContext)
|
||||
promise.resolve(snapshotMap(forceIdle = true))
|
||||
}.onFailure { promise.reject("FIGHTRATE_MONITOR_STOP_FAILED", it) }
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
fun getSnapshot(promise: Promise) {
|
||||
runCatching { promise.resolve(snapshotMap()) }
|
||||
.onFailure { promise.reject("FIGHTRATE_SNAPSHOT_FAILED", it) }
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
fun getSessionSamples(sessionId: String, promise: Promise) {
|
||||
runCatching {
|
||||
val id = sessionId.toLongOrNull() ?: return@runCatching Arguments.createArray()
|
||||
samplesToWritableArray(store.samplesForSession(id, MAX_SESSION_DETAIL_SAMPLES))
|
||||
}.onSuccess { promise.resolve(it) }
|
||||
.onFailure { promise.reject("FIGHTRATE_SESSION_SAMPLES_FAILED", it) }
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
fun getContinuousSamples(limit: Double, promise: Promise) {
|
||||
runCatching {
|
||||
val requestedLimit = limit.toInt()
|
||||
val samples = if (requestedLimit <= 0) {
|
||||
store.allContinuousSamples()
|
||||
} else {
|
||||
store.recentSamples(requestedLimit.coerceIn(1, MAX_CONTINUOUS_HISTORY_SAMPLES))
|
||||
}
|
||||
samplesToWritableArray(samples)
|
||||
}.onSuccess { promise.resolve(it) }
|
||||
.onFailure { promise.reject("FIGHTRATE_CONTINUOUS_SAMPLES_FAILED", it) }
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
fun isIgnoringBatteryOptimizations(promise: Promise) {
|
||||
promise.resolve(isBatteryOptimizationIgnored())
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
fun openBatteryOptimizationSettings(promise: Promise) {
|
||||
runCatching {
|
||||
val packageName = reactContext.packageName
|
||||
val intent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && !isBatteryOptimizationIgnored()) {
|
||||
Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply {
|
||||
data = Uri.parse("package:$packageName")
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
} else {
|
||||
Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS).apply {
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
}
|
||||
reactContext.startActivity(intent)
|
||||
promise.resolve(true)
|
||||
}.onFailure { promise.reject("FIGHTRATE_BATTERY_SETTINGS_FAILED", it) }
|
||||
}
|
||||
|
||||
private fun registerReceiverIfNeeded() {
|
||||
if (receiverRegistered) return
|
||||
val filter = IntentFilter().apply {
|
||||
addAction(FightRateCaptureService.ACTION_SAMPLE)
|
||||
addAction(FightRateCaptureService.ACTION_STATUS)
|
||||
addAction(FightRateCaptureService.ACTION_DEVICES)
|
||||
addAction(FightRateCaptureService.ACTION_METADATA)
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
reactContext.registerReceiver(receiver, filter, Context.RECEIVER_NOT_EXPORTED)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
reactContext.registerReceiver(receiver, filter)
|
||||
}
|
||||
receiverRegistered = true
|
||||
}
|
||||
|
||||
private fun unregisterReceiverIfNeeded() {
|
||||
if (!receiverRegistered) return
|
||||
runCatching { reactContext.unregisterReceiver(receiver) }
|
||||
receiverRegistered = false
|
||||
}
|
||||
|
||||
private fun restoreMonitoringIfNeeded() {
|
||||
val context = reactContext.applicationContext
|
||||
if (FightRateCaptureService.isMonitoringEnabled(context)) {
|
||||
FightRateCaptureService.startMonitoring(context)
|
||||
}
|
||||
}
|
||||
|
||||
private fun snapshotMap(forceIdle: Boolean = false): WritableMap {
|
||||
val active = if (forceIdle) null else store.activeSession()
|
||||
val context = reactContext.applicationContext
|
||||
val monitoring = !forceIdle && FightRateCaptureService.isMonitoringEnabled(context)
|
||||
val sessions = store.finishedSessionSummaries(MAX_SESSION_SUMMARIES)
|
||||
val samples = store.recentSamples(MAX_SNAPSHOT_SAMPLES)
|
||||
val latestSample = samples.lastOrNull()
|
||||
val hasFreshSample = latestSample?.let { FightRateCaptureService.isFreshTimestamp(it.timestampMillis) } == true
|
||||
val connectedDevice = FightRateCaptureService.lastKnownDevice(context)?.let { nativeDeviceMap(it) }
|
||||
?: active?.let { sessionDeviceMap(it) }
|
||||
val map = Arguments.createMap()
|
||||
val phase = when {
|
||||
forceIdle -> FightRateCaptureService.PHASE_IDLE
|
||||
!monitoring -> FightRateCaptureService.PHASE_IDLE
|
||||
samples.isEmpty() -> FightRateCaptureService.PHASE_SCANNING
|
||||
hasFreshSample -> FightRateCaptureService.PHASE_RECEIVING
|
||||
else -> FightRateCaptureService.PHASE_CONNECTED
|
||||
}
|
||||
map.putString("phase", phase)
|
||||
map.putBoolean("isMonitoring", monitoring)
|
||||
map.putBoolean("isCapturing", active != null && !forceIdle)
|
||||
map.putDouble("startedAt", (active?.startedAtMillis ?: 0L).toDouble())
|
||||
map.putString("statusMessage", when {
|
||||
forceIdle || !monitoring -> "Monitor parado."
|
||||
active != null && hasFreshSample -> "Janela de treino ativa. Monitor contínuo gravando."
|
||||
active != null -> "Janela ativa, mas sem BPM válido recente. Aguardando sensor."
|
||||
hasFreshSample -> "Monitor contínuo ativo. Recebendo BPM real."
|
||||
else -> "Monitor contínuo ativo. Sem batimento válido recente."
|
||||
})
|
||||
map.putMap("connectedDevice", connectedDevice)
|
||||
map.putArray("discoveredDevices", Arguments.createArray())
|
||||
map.putArray("samples", samplesToWritableArray(samples))
|
||||
map.putArray("sessions", sessionsToWritableArray(sessions))
|
||||
map.putArray("services", Arguments.createArray())
|
||||
map.putArray("characteristics", Arguments.createArray())
|
||||
map.putBoolean("batteryOptimizationIgnored", isBatteryOptimizationIgnored())
|
||||
return map
|
||||
}
|
||||
|
||||
private fun sessionDeviceMap(session: StoredSession): WritableMap? {
|
||||
if (session.deviceId.isNullOrBlank() && session.deviceName.isNullOrBlank()) return null
|
||||
return Arguments.createMap().apply {
|
||||
putString("id", session.deviceId ?: "")
|
||||
putString("name", session.deviceName ?: "Cinta cardíaca BLE")
|
||||
putInt("rssi", Int.MIN_VALUE)
|
||||
putInt("batteryPercent", -1)
|
||||
putString("bodySensorLocation", null)
|
||||
}
|
||||
}
|
||||
|
||||
private fun nativeDeviceMap(device: NativeDiscoveredDevice): WritableMap = Arguments.createMap().apply {
|
||||
putString("id", device.id)
|
||||
putString("name", device.name)
|
||||
putInt("rssi", device.rssi)
|
||||
putInt("batteryPercent", -1)
|
||||
putString("bodySensorLocation", null)
|
||||
}
|
||||
|
||||
private fun sampleFromIntent(intent: Intent): WritableMap = Arguments.createMap().apply {
|
||||
putDouble("id", intent.getLongExtra(FightRateCaptureService.EXTRA_SAMPLE_ID, -1L).toDouble())
|
||||
val sessionId = intent.getLongExtra(FightRateCaptureService.EXTRA_SESSION_ID, -1L)
|
||||
if (sessionId >= 0L) putDouble("sessionId", sessionId.toDouble()) else putNull("sessionId")
|
||||
putDouble("timestamp", intent.getLongExtra(FightRateCaptureService.EXTRA_TIMESTAMP, 0L).toDouble())
|
||||
putInt("bpm", intent.getIntExtra(FightRateCaptureService.EXTRA_BPM, 0))
|
||||
if (intent.hasExtra(FightRateCaptureService.EXTRA_CONTACT_DETECTED)) {
|
||||
putBoolean("contactDetected", intent.getBooleanExtra(FightRateCaptureService.EXTRA_CONTACT_DETECTED, false))
|
||||
} else {
|
||||
putNull("contactDetected")
|
||||
}
|
||||
putArray("rrIntervalsMs", intArrayListToWritable(intent.getIntegerArrayListExtra(FightRateCaptureService.EXTRA_RR_INTERVALS)))
|
||||
putString("deviceName", intent.getStringExtra(FightRateCaptureService.EXTRA_DEVICE_NAME))
|
||||
}
|
||||
|
||||
private fun statusFromIntent(intent: Intent): WritableMap = Arguments.createMap().apply {
|
||||
putString("phase", intent.getStringExtra(FightRateCaptureService.EXTRA_PHASE) ?: FightRateCaptureService.PHASE_IDLE)
|
||||
putString("statusMessage", intent.getStringExtra(FightRateCaptureService.EXTRA_STATUS_MESSAGE))
|
||||
val sessionId = intent.getLongExtra(FightRateCaptureService.EXTRA_SESSION_ID, -1L)
|
||||
if (sessionId >= 0L) putDouble("sessionId", sessionId.toDouble()) else putNull("sessionId")
|
||||
putMap("connectedDevice", Arguments.createMap().apply {
|
||||
putString("id", intent.getStringExtra(FightRateCaptureService.EXTRA_DEVICE_ID) ?: "")
|
||||
putString("name", intent.getStringExtra(FightRateCaptureService.EXTRA_DEVICE_NAME) ?: "Cinta cardíaca BLE")
|
||||
putInt("rssi", intent.getIntExtra(FightRateCaptureService.EXTRA_RSSI, Int.MIN_VALUE))
|
||||
putInt("batteryPercent", intent.getIntExtra(FightRateCaptureService.EXTRA_BATTERY_PERCENT, -1))
|
||||
putString("bodySensorLocation", intent.getStringExtra(FightRateCaptureService.EXTRA_BODY_SENSOR_LOCATION))
|
||||
})
|
||||
}
|
||||
|
||||
private fun devicesFromIntent(intent: Intent): WritableMap = Arguments.createMap().apply {
|
||||
val array = Arguments.createArray()
|
||||
@Suppress("DEPRECATION")
|
||||
val devices = intent.getParcelableArrayListExtra<Bundle>(FightRateCaptureService.EXTRA_DEVICES).orEmpty()
|
||||
devices.forEach { bundle ->
|
||||
array.pushMap(Arguments.createMap().apply {
|
||||
putString("id", bundle.getString("id") ?: "")
|
||||
putString("name", bundle.getString("name") ?: "Sensor BLE sem nome")
|
||||
putInt("rssi", bundle.getInt("rssi", Int.MIN_VALUE))
|
||||
putArray("serviceUuids", stringArrayListToWritable(bundle.getStringArrayList("serviceUuids")))
|
||||
})
|
||||
}
|
||||
putArray("devices", array)
|
||||
}
|
||||
|
||||
private fun metadataFromIntent(intent: Intent): WritableMap = Arguments.createMap().apply {
|
||||
putArray("services", stringArrayListToWritable(intent.getStringArrayListExtra(FightRateCaptureService.EXTRA_SERVICES)))
|
||||
putArray("characteristics", stringArrayListToWritable(intent.getStringArrayListExtra(FightRateCaptureService.EXTRA_CHARACTERISTICS)))
|
||||
}
|
||||
|
||||
private fun samplesToWritableArray(samples: List<StoredSample>): WritableArray = Arguments.createArray().also { array ->
|
||||
samples.forEach { sample ->
|
||||
array.pushMap(Arguments.createMap().apply {
|
||||
putDouble("id", sample.id.toDouble())
|
||||
sample.sessionId?.let { putDouble("sessionId", it.toDouble()) } ?: putNull("sessionId")
|
||||
putDouble("timestamp", sample.timestampMillis.toDouble())
|
||||
putInt("bpm", sample.bpm)
|
||||
if (sample.contactDetected == null) putNull("contactDetected") else putBoolean("contactDetected", sample.contactDetected)
|
||||
putArray("rrIntervalsMs", intArrayListToWritable(ArrayList(sample.rrIntervalsMs)))
|
||||
putString("deviceName", sample.deviceName)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private fun sessionsToWritableArray(sessions: List<StoredSessionSummary>): WritableArray = Arguments.createArray().also { array ->
|
||||
sessions.forEach { session ->
|
||||
array.pushMap(Arguments.createMap().apply {
|
||||
putString("id", session.id.toString())
|
||||
putDouble("startedAt", session.startedAtMillis.toDouble())
|
||||
putDouble("endedAt", session.endedAtMillis.toDouble())
|
||||
putDouble("durationMs", session.durationMillis.toDouble())
|
||||
putString("deviceName", session.deviceName)
|
||||
putInt("sampleCount", session.sampleCount)
|
||||
putInt("averageBpm", session.averageBpm)
|
||||
putInt("peakBpm", session.peakBpm)
|
||||
putInt("minBpm", session.minBpm)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private fun intArrayListToWritable(values: ArrayList<Int>?): WritableArray = Arguments.createArray().also { array ->
|
||||
values.orEmpty().forEach { array.pushInt(it) }
|
||||
}
|
||||
|
||||
private fun stringArrayListToWritable(values: ArrayList<String>?): WritableArray = Arguments.createArray().also { array ->
|
||||
values.orEmpty().forEach { array.pushString(it) }
|
||||
}
|
||||
|
||||
private fun emit(eventName: String, payload: WritableMap) {
|
||||
reactContext
|
||||
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
|
||||
.emit(eventName, payload)
|
||||
}
|
||||
|
||||
private fun isBatteryOptimizationIgnored(): Boolean {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return true
|
||||
val powerManager = reactContext.getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||
return powerManager.isIgnoringBatteryOptimizations(reactContext.packageName)
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val NAME = "FightRateCapture"
|
||||
private const val EVENT_SAMPLE = "FightRateSample"
|
||||
private const val EVENT_STATUS = "FightRateStatus"
|
||||
private const val EVENT_DEVICES = "FightRateDevices"
|
||||
private const val EVENT_METADATA = "FightRateMetadata"
|
||||
private const val MAX_SNAPSHOT_SAMPLES = 1800
|
||||
private const val MAX_SESSION_SUMMARIES = 60
|
||||
private const val MAX_SESSION_DETAIL_SAMPLES = 7200
|
||||
private const val MAX_CONTINUOUS_HISTORY_SAMPLES = 20_000
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,892 @@
|
||||
package com.healthtracking.fightrate.capture
|
||||
|
||||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.bluetooth.BluetoothDevice
|
||||
import android.bluetooth.BluetoothGatt
|
||||
import android.bluetooth.BluetoothGattCallback
|
||||
import android.bluetooth.BluetoothGattCharacteristic
|
||||
import android.bluetooth.BluetoothGattDescriptor
|
||||
import android.bluetooth.BluetoothStatusCodes
|
||||
import android.bluetooth.BluetoothGattService
|
||||
import android.bluetooth.BluetoothManager
|
||||
import android.bluetooth.BluetoothProfile
|
||||
import android.bluetooth.le.ScanCallback
|
||||
import android.bluetooth.le.ScanResult
|
||||
import android.bluetooth.le.ScanSettings
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.IBinder
|
||||
import android.os.Looper
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.healthtracking.fightrate.MainActivity
|
||||
import com.healthtracking.fightrate.R
|
||||
import java.util.UUID
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
class FightRateCaptureService : Service() {
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
private lateinit var store: FightRateCaptureStore
|
||||
private val bluetoothManager by lazy { getSystemService(BluetoothManager::class.java) }
|
||||
private val bluetoothAdapter by lazy { bluetoothManager?.adapter }
|
||||
private val bluetoothScanner get() = bluetoothAdapter?.bluetoothLeScanner
|
||||
private var gatt: BluetoothGatt? = null
|
||||
private var activeSessionId: Long? = null
|
||||
private var manualStop = true
|
||||
private var currentDeviceId: String? = null
|
||||
private var currentDeviceName: String? = null
|
||||
private var currentRssi: Int? = null
|
||||
private var batteryPercent: Int? = null
|
||||
private var bodySensorLocation: String? = null
|
||||
private var reconnectAttempts = 0
|
||||
private val discoveredDevices = linkedMapOf<String, NativeDiscoveredDevice>()
|
||||
private var lastNotificationUpdateMillis = 0L
|
||||
private var lastRssiReadMillis = 0L
|
||||
private var lastNotificationBpm: Int? = null
|
||||
private var recentBpm = ArrayDeque<Pair<Long, Int>>()
|
||||
private var lastValidMeasurementMillis = 0L
|
||||
private var lastInvalidMeasurementMillis = 0L
|
||||
private var notifyRetryCount = 0
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
store = FightRateCaptureStore(applicationContext)
|
||||
createNotificationChannel()
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
when (intent?.action) {
|
||||
ACTION_START_MONITORING -> startMonitoring(intent.getStringExtra(EXTRA_DEVICE_ID), intent.getStringExtra(EXTRA_DEVICE_NAME))
|
||||
ACTION_START -> startWorkoutWindow(intent.getStringExtra(EXTRA_DEVICE_ID), intent.getStringExtra(EXTRA_DEVICE_NAME))
|
||||
ACTION_CONNECT_DEVICE -> connectKnownDevice(intent.getStringExtra(EXTRA_DEVICE_ID), intent.getStringExtra(EXTRA_DEVICE_NAME))
|
||||
ACTION_STOP_WINDOW -> stopWorkoutWindow(manual = true)
|
||||
ACTION_STOP -> stopMonitoring(manual = true)
|
||||
else -> restoreCaptureIfNeeded()
|
||||
}
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onDestroy() {
|
||||
stopScan()
|
||||
closeGatt()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
private fun startMonitoring(deviceId: String?, deviceName: String?) {
|
||||
manualStop = false
|
||||
currentDeviceId = deviceId ?: currentDeviceId ?: prefs().getString(KEY_LAST_DEVICE_ID, null)
|
||||
currentDeviceName = deviceName ?: currentDeviceName ?: prefs().getString(KEY_LAST_DEVICE_NAME, null)
|
||||
currentRssi = currentRssi ?: lastStoredRssi()
|
||||
persistState(manual = false)
|
||||
startForeground(NOTIFICATION_ID, buildNotification("Monitor cardíaco contínuo ativo..."))
|
||||
emitStatus(PHASE_SCANNING, "Monitor contínuo ativo. Procurando a cinta cardíaca BLE.")
|
||||
|
||||
if (gatt != null) {
|
||||
emitStatus(PHASE_RECEIVING, "Monitor contínuo recebendo BPM real.")
|
||||
return
|
||||
}
|
||||
|
||||
val knownDeviceId = currentDeviceId
|
||||
if (!knownDeviceId.isNullOrBlank()) {
|
||||
connectKnownDevice(knownDeviceId, currentDeviceName)
|
||||
} else {
|
||||
startScan()
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
private fun startWorkoutWindow(deviceId: String?, deviceName: String?) {
|
||||
startMonitoring(deviceId, deviceName)
|
||||
val session = store.startSession(System.currentTimeMillis(), currentDeviceId, currentDeviceName)
|
||||
activeSessionId = session.id
|
||||
persistState(manual = false)
|
||||
emitStatus(PHASE_RECEIVING, "Janela de treino aberta. O monitor segue gravando continuamente.")
|
||||
updateNotification("Treino em andamento · monitor contínuo ativo")
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
private fun connectKnownDevice(deviceId: String?, deviceName: String?) {
|
||||
if (deviceId.isNullOrBlank()) {
|
||||
startScan()
|
||||
return
|
||||
}
|
||||
if (!hasConnectPermission()) {
|
||||
emitStatus(PHASE_PERMISSION_DENIED, "Permissão de conexão Bluetooth não concedida.")
|
||||
return
|
||||
}
|
||||
val adapter = bluetoothAdapter
|
||||
if (adapter?.isEnabled != true) {
|
||||
emitStatus(PHASE_BLUETOOTH_OFF, "Bluetooth desligado.")
|
||||
return
|
||||
}
|
||||
|
||||
currentDeviceId = deviceId
|
||||
currentDeviceName = deviceName ?: currentDeviceName ?: "Cinta cardíaca BLE"
|
||||
activeSessionId?.let { store.updateSessionDevice(it, currentDeviceId, currentDeviceName) }
|
||||
persistState(manual = false)
|
||||
stopScan()
|
||||
closeGatt()
|
||||
emitStatus(PHASE_CONNECTING, "Conectando em ${friendlyDeviceName(currentDeviceName)}...")
|
||||
updateNotification("Conectando em ${friendlyDeviceName(currentDeviceName)}...")
|
||||
|
||||
runCatching {
|
||||
val device = adapter.getRemoteDevice(deviceId)
|
||||
gatt = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
device.connectGatt(this, false, gattCallback, BluetoothDevice.TRANSPORT_LE)
|
||||
} else {
|
||||
device.connectGatt(this, false, gattCallback)
|
||||
}
|
||||
}.onFailure { error ->
|
||||
emitStatus(PHASE_ERROR, error.message ?: "Falha ao abrir conexão BLE.")
|
||||
scheduleReconnect()
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
private fun startScan() {
|
||||
if (!hasScanPermission()) {
|
||||
emitStatus(PHASE_PERMISSION_DENIED, "Permissão de busca Bluetooth não concedida.")
|
||||
return
|
||||
}
|
||||
val adapter = bluetoothAdapter
|
||||
if (adapter?.isEnabled != true) {
|
||||
emitStatus(PHASE_BLUETOOTH_OFF, "Bluetooth desligado.")
|
||||
return
|
||||
}
|
||||
|
||||
stopScan()
|
||||
discoveredDevices.clear()
|
||||
emitDevices()
|
||||
emitStatus(PHASE_SCANNING, "Buscando sensores cardíacos BLE próximos.")
|
||||
updateNotification("Buscando cinta cardíaca BLE...")
|
||||
|
||||
val settings = ScanSettings.Builder()
|
||||
.setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
|
||||
.build()
|
||||
bluetoothScanner?.startScan(null, settings, scanCallback)
|
||||
mainHandler.removeCallbacks(scanTimeoutRunnable)
|
||||
mainHandler.postDelayed(scanTimeoutRunnable, SCAN_WINDOW_MILLIS)
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
private fun stopScan() {
|
||||
runCatching { bluetoothScanner?.stopScan(scanCallback) }
|
||||
mainHandler.removeCallbacks(scanTimeoutRunnable)
|
||||
}
|
||||
|
||||
private val scanTimeoutRunnable = Runnable {
|
||||
if (manualStop || gatt != null) return@Runnable
|
||||
emitStatus(PHASE_SENSOR_NOT_FOUND, "Nenhuma cinta cardíaca encontrada. Vou tentar de novo em 15s.")
|
||||
updateNotification("Sensor não encontrado. Nova busca em 15s...")
|
||||
mainHandler.postDelayed({ if (!manualStop && gatt == null) startScan() }, SCAN_RETRY_MILLIS)
|
||||
}
|
||||
|
||||
private val scanCallback = object : ScanCallback() {
|
||||
override fun onScanResult(callbackType: Int, result: ScanResult) {
|
||||
handleScanResult(result)
|
||||
}
|
||||
|
||||
override fun onBatchScanResults(results: MutableList<ScanResult>) {
|
||||
results.forEach(::handleScanResult)
|
||||
}
|
||||
|
||||
override fun onScanFailed(errorCode: Int) {
|
||||
emitStatus(PHASE_ERROR, "Falha no scan BLE: $errorCode")
|
||||
mainHandler.postDelayed({ if (!manualStop) startScan() }, SCAN_RETRY_MILLIS)
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
private fun handleScanResult(result: ScanResult) {
|
||||
val device = result.device ?: return
|
||||
val name = result.scanRecord?.deviceName ?: device.name ?: "Sensor BLE sem nome"
|
||||
val serviceUuids = result.scanRecord?.serviceUuids?.map { it.uuid.toString() }.orEmpty()
|
||||
val candidate = NativeDiscoveredDevice(device.address, name, result.rssi, serviceUuids)
|
||||
discoveredDevices[device.address] = candidate
|
||||
emitDevices()
|
||||
|
||||
if (!isHeartRateCandidate(candidate)) return
|
||||
currentRssi = result.rssi
|
||||
persistState(manual = false)
|
||||
connectKnownDevice(device.address, name)
|
||||
}
|
||||
|
||||
private val gattCallback = object : BluetoothGattCallback() {
|
||||
override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) {
|
||||
if (manualStop) return
|
||||
when {
|
||||
status != BluetoothGatt.GATT_SUCCESS -> {
|
||||
emitStatus(PHASE_ERROR, "Falha GATT: $status. Tentando reconectar.")
|
||||
closeGatt(gatt)
|
||||
scheduleReconnect()
|
||||
}
|
||||
newState == BluetoothProfile.STATE_CONNECTED -> {
|
||||
reconnectAttempts = 0
|
||||
currentDeviceId = gatt.device.address
|
||||
currentDeviceName = safeDeviceName(gatt.device)
|
||||
activeSessionId?.let { store.updateSessionDevice(it, currentDeviceId, currentDeviceName) }
|
||||
persistState(manual = false)
|
||||
emitStatus(PHASE_CONNECTED, "Sensor conectado. Descobrindo serviços.")
|
||||
readRssiNow(gatt)
|
||||
updateNotification("Sensor conectado. Preparando leitura...")
|
||||
if (!gatt.discoverServices()) {
|
||||
emitStatus(PHASE_ERROR, "Não foi possível descobrir serviços BLE.")
|
||||
scheduleReconnect()
|
||||
}
|
||||
}
|
||||
newState == BluetoothProfile.STATE_DISCONNECTED -> {
|
||||
closeGatt(gatt)
|
||||
emitStatus(PHASE_ERROR, "Sensor desconectado. Tentando reconectar.")
|
||||
scheduleReconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) {
|
||||
if (manualStop) return
|
||||
if (status != BluetoothGatt.GATT_SUCCESS) {
|
||||
emitStatus(PHASE_ERROR, "Falha ao descobrir serviços BLE: $status")
|
||||
scheduleReconnect()
|
||||
return
|
||||
}
|
||||
emitMetadata(gatt.services)
|
||||
val service = gatt.getService(HEART_RATE_SERVICE_UUID)
|
||||
if (service == null) {
|
||||
emitStatus(PHASE_ERROR, "Serviço Heart Rate 180D não encontrado no sensor.")
|
||||
scheduleReconnect()
|
||||
return
|
||||
}
|
||||
subscribeToHeartRate(gatt, service)
|
||||
}
|
||||
|
||||
@Deprecated("Deprecated by Android framework")
|
||||
override fun onCharacteristicChanged(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic) {
|
||||
handleMeasurement(characteristic.value)
|
||||
}
|
||||
|
||||
override fun onCharacteristicChanged(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, value: ByteArray) {
|
||||
handleMeasurement(value)
|
||||
}
|
||||
|
||||
override fun onDescriptorWrite(gatt: BluetoothGatt, descriptor: BluetoothGattDescriptor, status: Int) {
|
||||
if (manualStop || descriptor.uuid != CLIENT_CHARACTERISTIC_CONFIG_UUID) return
|
||||
val characteristic = descriptor.characteristic
|
||||
if (characteristic?.uuid != HEART_RATE_MEASUREMENT_UUID) return
|
||||
|
||||
if (status == BluetoothGatt.GATT_SUCCESS) {
|
||||
notifyRetryCount = 0
|
||||
emitStatus(PHASE_RECEIVING, "2A37 confirmado. Aguardando o primeiro BPM real.")
|
||||
updateNotification("2A37 ativo. Aguardando batimentos reais...")
|
||||
readRssiNow(gatt)
|
||||
readBodyLocationOrBattery(gatt)
|
||||
scheduleMeasurementWatchdog(gatt)
|
||||
} else {
|
||||
emitStatus(PHASE_ERROR, "Falha ao ativar notify 2A37: $status. Tentando reconectar.")
|
||||
scheduleReconnect()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onReadRemoteRssi(gatt: BluetoothGatt, rssi: Int, status: Int) {
|
||||
if (status == BluetoothGatt.GATT_SUCCESS) {
|
||||
currentRssi = rssi
|
||||
persistState(manual = false)
|
||||
emitStatus(PHASE_RECEIVING, null)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCharacteristicRead(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, status: Int) {
|
||||
if (status != BluetoothGatt.GATT_SUCCESS) return
|
||||
when (characteristic.uuid) {
|
||||
BATTERY_LEVEL_UUID -> {
|
||||
batteryPercent = characteristic.value?.firstOrNull()?.toInt()?.and(0xFF)?.coerceIn(0, 100)
|
||||
emitStatus(PHASE_RECEIVING, null)
|
||||
}
|
||||
BODY_SENSOR_LOCATION_UUID -> {
|
||||
bodySensorLocation = bodyLocationName(characteristic.value?.firstOrNull()?.toInt()?.and(0xFF))
|
||||
readBattery(gatt)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
private fun subscribeToHeartRate(gatt: BluetoothGatt, service: BluetoothGattService) {
|
||||
val characteristic = service.getCharacteristic(HEART_RATE_MEASUREMENT_UUID)
|
||||
if (characteristic == null) {
|
||||
emitStatus(PHASE_ERROR, "Característica Heart Rate Measurement 2A37 não encontrada.")
|
||||
scheduleReconnect()
|
||||
return
|
||||
}
|
||||
|
||||
val supportsNotify = characteristic.properties and BluetoothGattCharacteristic.PROPERTY_NOTIFY != 0
|
||||
val supportsIndicate = characteristic.properties and BluetoothGattCharacteristic.PROPERTY_INDICATE != 0
|
||||
if (!supportsNotify && !supportsIndicate) {
|
||||
emitStatus(PHASE_ERROR, "2A37 existe, mas não expõe notify/indicate.")
|
||||
scheduleReconnect()
|
||||
return
|
||||
}
|
||||
|
||||
if (!gatt.setCharacteristicNotification(characteristic, true)) {
|
||||
emitStatus(PHASE_ERROR, "Android recusou setCharacteristicNotification em 2A37.")
|
||||
scheduleReconnect()
|
||||
return
|
||||
}
|
||||
|
||||
val descriptor = characteristic.getDescriptor(CLIENT_CHARACTERISTIC_CONFIG_UUID)
|
||||
if (descriptor == null) {
|
||||
emitStatus(PHASE_ERROR, "Descriptor CCCD 2902 não encontrado para 2A37.")
|
||||
scheduleReconnect()
|
||||
return
|
||||
}
|
||||
|
||||
val descriptorValue = if (supportsNotify) {
|
||||
BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
|
||||
} else {
|
||||
BluetoothGattDescriptor.ENABLE_INDICATION_VALUE
|
||||
}
|
||||
val writeStarted = writeDescriptorCompat(gatt, descriptor, descriptorValue)
|
||||
if (!writeStarted) {
|
||||
emitStatus(PHASE_ERROR, "Não foi possível escrever CCCD 2902 para 2A37.")
|
||||
scheduleReconnect()
|
||||
return
|
||||
}
|
||||
|
||||
emitStatus(PHASE_CONNECTED, "Ativando notify 2A37. Dados serão salvos assim que chegarem.")
|
||||
updateNotification("Ativando leitura 2A37...")
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
private fun writeDescriptorCompat(
|
||||
gatt: BluetoothGatt,
|
||||
descriptor: BluetoothGattDescriptor,
|
||||
value: ByteArray,
|
||||
): Boolean {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
gatt.writeDescriptor(descriptor, value) == BluetoothStatusCodes.SUCCESS
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
descriptor.value = value
|
||||
@Suppress("DEPRECATION")
|
||||
gatt.writeDescriptor(descriptor)
|
||||
}
|
||||
}
|
||||
|
||||
private fun scheduleMeasurementWatchdog(gatt: BluetoothGatt) {
|
||||
val watchedGatt = gatt
|
||||
val armedAt = System.currentTimeMillis()
|
||||
mainHandler.postDelayed({
|
||||
if (manualStop || this.gatt !== watchedGatt) return@postDelayed
|
||||
val hasReadingAfterArm = lastValidMeasurementMillis >= armedAt
|
||||
if (hasReadingAfterArm) return@postDelayed
|
||||
val hasInvalidAfterArm = lastInvalidMeasurementMillis >= armedAt
|
||||
if (hasInvalidAfterArm) {
|
||||
emitStatus(PHASE_CONNECTED, "Sensor conectado, mas sem batimento válido. Aguardando contato/sinal real.")
|
||||
updateNotification("Sem BPM válido · ajuste a cinta/sensor")
|
||||
scheduleSignalStaleWatchdog()
|
||||
return@postDelayed
|
||||
}
|
||||
|
||||
notifyRetryCount += 1
|
||||
if (notifyRetryCount <= 2) {
|
||||
emitStatus(PHASE_CONNECTED, "Sensor reconhecido, mas ainda sem BPM. Reativando notify 2A37.")
|
||||
watchedGatt.getService(HEART_RATE_SERVICE_UUID)?.let { subscribeToHeartRate(watchedGatt, it) }
|
||||
} else {
|
||||
emitStatus(PHASE_ERROR, "Sensor reconhecido sem enviar BPM. Reconectando para reabrir 2A37.")
|
||||
scheduleReconnect()
|
||||
}
|
||||
}, FIRST_MEASUREMENT_TIMEOUT_MILLIS)
|
||||
}
|
||||
|
||||
private fun handleMeasurement(value: ByteArray?) {
|
||||
if (manualStop || value == null || value.isEmpty()) return
|
||||
val parsed = runCatching { HeartRateMeasurementParser.parse(value) }.getOrNull() ?: return
|
||||
val now = System.currentTimeMillis()
|
||||
|
||||
if (!isValidMeasurement(parsed)) {
|
||||
handleInvalidMeasurement(parsed, now)
|
||||
return
|
||||
}
|
||||
|
||||
lastValidMeasurementMillis = now
|
||||
val sessionId = activeSessionId ?: store.activeSession()?.id
|
||||
val sample = store.insertSample(
|
||||
activeSessionId = sessionId,
|
||||
timestampMillis = now,
|
||||
bpm = parsed.bpm,
|
||||
contactDetected = parsed.contactDetected,
|
||||
rrIntervalsMs = parsed.rrIntervalsMs,
|
||||
deviceName = currentDeviceName,
|
||||
)
|
||||
reconnectAttempts = 0
|
||||
addRecentBpm(now, parsed.bpm)
|
||||
emitSample(sample)
|
||||
emitStatus(PHASE_RECEIVING, "Recebendo BPM real de ${friendlyDeviceName(currentDeviceName)}.")
|
||||
updateNotificationIfNeeded(now, parsed.bpm)
|
||||
readRssiEventually()
|
||||
scheduleSignalStaleWatchdog()
|
||||
}
|
||||
|
||||
private fun handleInvalidMeasurement(parsed: ParsedHeartRateMeasurement, timestamp: Long) {
|
||||
lastInvalidMeasurementMillis = timestamp
|
||||
recentBpm.clear()
|
||||
lastNotificationBpm = null
|
||||
val reason = invalidMeasurementReason(parsed)
|
||||
emitStatus(PHASE_CONNECTED, "Sensor conectado, mas $reason. Aguardando batimento real.")
|
||||
updateNotification("Sem BPM válido · $reason")
|
||||
scheduleSignalStaleWatchdog()
|
||||
}
|
||||
|
||||
private fun isValidMeasurement(parsed: ParsedHeartRateMeasurement): Boolean {
|
||||
return parsed.bpm in MIN_VALID_BPM..MAX_VALID_BPM && parsed.contactDetected != false
|
||||
}
|
||||
|
||||
private fun invalidMeasurementReason(parsed: ParsedHeartRateMeasurement): String {
|
||||
return when {
|
||||
parsed.contactDetected == false -> "sem contato detectado"
|
||||
parsed.bpm <= 0 -> "o sensor reportou 0 bpm"
|
||||
parsed.bpm < MIN_VALID_BPM -> "BPM abaixo do plausível"
|
||||
parsed.bpm > MAX_VALID_BPM -> "BPM acima do plausível"
|
||||
else -> "sem sinal válido"
|
||||
}
|
||||
}
|
||||
|
||||
private val signalStaleRunnable = Runnable {
|
||||
if (manualStop || gatt == null) return@Runnable
|
||||
val now = System.currentTimeMillis()
|
||||
val elapsed = now - lastValidMeasurementMillis
|
||||
if (lastValidMeasurementMillis > 0L && elapsed >= VALID_SAMPLE_STALE_MILLIS) {
|
||||
recentBpm.clear()
|
||||
lastNotificationBpm = null
|
||||
emitStatus(PHASE_CONNECTED, "Sem batimento válido há ${elapsed / 1000}s. Aguardando novos dados reais.")
|
||||
updateNotification("Sem BPM válido · aguardando sensor")
|
||||
}
|
||||
}
|
||||
|
||||
private fun scheduleSignalStaleWatchdog() {
|
||||
mainHandler.removeCallbacks(signalStaleRunnable)
|
||||
mainHandler.postDelayed(signalStaleRunnable, VALID_SAMPLE_STALE_MILLIS)
|
||||
}
|
||||
|
||||
private fun currentSignalPhase(): String {
|
||||
if (gatt == null) return PHASE_SCANNING
|
||||
return if (FightRateCaptureService.isFreshTimestamp(lastValidMeasurementMillis)) {
|
||||
PHASE_RECEIVING
|
||||
} else {
|
||||
PHASE_CONNECTED
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
private fun readRssiEventually() {
|
||||
val now = System.currentTimeMillis()
|
||||
if (now - lastRssiReadMillis < RSSI_READ_INTERVAL_MILLIS) return
|
||||
lastRssiReadMillis = now
|
||||
readRssiNow(gatt)
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
private fun readRssiNow(target: BluetoothGatt?) {
|
||||
if (!hasConnectPermission()) return
|
||||
runCatching { target?.readRemoteRssi() }
|
||||
}
|
||||
|
||||
private fun stopWorkoutWindow(manual: Boolean) {
|
||||
store.finishActiveSession(System.currentTimeMillis())
|
||||
activeSessionId = null
|
||||
val message = if (manual) "Treino salvo. Monitor cardíaco continua ativo." else "Janela de treino encerrada."
|
||||
emitStatus(currentSignalPhase(), message)
|
||||
updateNotification(if (currentSignalPhase() == PHASE_RECEIVING) "Monitor contínuo ativo · sem janela de treino" else "Monitor ativo · aguardando BPM válido")
|
||||
}
|
||||
|
||||
private fun stopMonitoring(manual: Boolean) {
|
||||
manualStop = manual
|
||||
persistState(manual = manual)
|
||||
stopScan()
|
||||
closeGatt()
|
||||
mainHandler.removeCallbacks(signalStaleRunnable)
|
||||
store.finishActiveSession(System.currentTimeMillis())
|
||||
activeSessionId = null
|
||||
recentBpm.clear()
|
||||
emitStatus(PHASE_IDLE, if (manual) "Monitor parado manualmente." else "Monitor encerrado.")
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
stopSelf()
|
||||
}
|
||||
|
||||
private fun restoreCaptureIfNeeded() {
|
||||
manualStop = prefs().getBoolean(KEY_MANUAL_STOP, true)
|
||||
if (manualStop) return
|
||||
activeSessionId = store.activeSession()?.id
|
||||
currentDeviceId = prefs().getString(KEY_LAST_DEVICE_ID, null)
|
||||
currentDeviceName = prefs().getString(KEY_LAST_DEVICE_NAME, null)
|
||||
startForeground(NOTIFICATION_ID, buildNotification("Retomando monitor contínuo..."))
|
||||
if (!currentDeviceId.isNullOrBlank()) connectKnownDevice(currentDeviceId, currentDeviceName) else startScan()
|
||||
}
|
||||
|
||||
private fun scheduleReconnect() {
|
||||
if (manualStop) return
|
||||
val delayMillis = when (reconnectAttempts++) {
|
||||
0 -> 2_000L
|
||||
1 -> 5_000L
|
||||
2 -> 10_000L
|
||||
else -> 30_000L
|
||||
}
|
||||
updateNotification("Reconectando em ${delayMillis / 1000}s...")
|
||||
mainHandler.postDelayed({
|
||||
if (manualStop) return@postDelayed
|
||||
val deviceId = currentDeviceId
|
||||
if (!deviceId.isNullOrBlank()) connectKnownDevice(deviceId, currentDeviceName) else startScan()
|
||||
}, delayMillis)
|
||||
}
|
||||
|
||||
private fun closeGatt() {
|
||||
runCatching { gatt?.disconnect() }
|
||||
runCatching { gatt?.close() }
|
||||
gatt = null
|
||||
mainHandler.removeCallbacks(signalStaleRunnable)
|
||||
}
|
||||
|
||||
private fun closeGatt(target: BluetoothGatt) {
|
||||
if (gatt === target) gatt = null
|
||||
runCatching { target.close() }
|
||||
}
|
||||
|
||||
private fun addRecentBpm(timestamp: Long, bpm: Int) {
|
||||
recentBpm.addLast(timestamp to bpm)
|
||||
val cutoff = timestamp - 60_000L
|
||||
while (recentBpm.isNotEmpty() && recentBpm.first().first < cutoff) {
|
||||
recentBpm.removeFirst()
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateNotificationIfNeeded(timestamp: Long, bpm: Int) {
|
||||
val rollingAverage = if (recentBpm.isEmpty()) bpm else recentBpm.map { it.second }.average().roundToInt()
|
||||
val changedEnough = lastNotificationBpm?.let { kotlin.math.abs(rollingAverage - it) >= 3 } ?: true
|
||||
if (timestamp - lastNotificationUpdateMillis < NOTIFICATION_THROTTLE_MILLIS && !changedEnough) return
|
||||
lastNotificationUpdateMillis = timestamp
|
||||
lastNotificationBpm = rollingAverage
|
||||
updateNotification("${rollingAverage} BPM média 60s · ${friendlyDeviceName(currentDeviceName)}")
|
||||
}
|
||||
|
||||
private fun buildNotification(text: String): Notification {
|
||||
val openIntent = PendingIntent.getActivity(
|
||||
this,
|
||||
0,
|
||||
Intent(this, MainActivity::class.java),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
val stopIntent = PendingIntent.getService(
|
||||
this,
|
||||
1,
|
||||
Intent(this, FightRateCaptureService::class.java).setAction(ACTION_STOP),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
|
||||
return NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setSmallIcon(R.mipmap.ic_launcher)
|
||||
.setContentTitle("FightRate capturando")
|
||||
.setContentText(text)
|
||||
.setContentIntent(openIntent)
|
||||
.setOngoing(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
.addAction(R.mipmap.ic_launcher, "Parar", stopIntent)
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun updateNotification(text: String) {
|
||||
getSystemService(NotificationManager::class.java).notify(NOTIFICATION_ID, buildNotification(text))
|
||||
}
|
||||
|
||||
private fun createNotificationChannel() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||
val channel = NotificationChannel(CHANNEL_ID, "Captura contínua", NotificationManager.IMPORTANCE_LOW).apply {
|
||||
description = "Mantém a cinta cardíaca BLE ativa com a tela bloqueada."
|
||||
}
|
||||
getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
|
||||
}
|
||||
|
||||
private fun emitSample(sample: StoredSample) {
|
||||
val intent = Intent(ACTION_SAMPLE).setPackage(packageName)
|
||||
.putExtra(EXTRA_SAMPLE_ID, sample.id)
|
||||
.putExtra(EXTRA_TIMESTAMP, sample.timestampMillis)
|
||||
.putExtra(EXTRA_BPM, sample.bpm)
|
||||
.putIntegerArrayListExtra(EXTRA_RR_INTERVALS, ArrayList(sample.rrIntervalsMs))
|
||||
.putExtra(EXTRA_DEVICE_NAME, sample.deviceName)
|
||||
sample.sessionId?.let { intent.putExtra(EXTRA_SESSION_ID, it) }
|
||||
sample.contactDetected?.let { intent.putExtra(EXTRA_CONTACT_DETECTED, it) }
|
||||
sendBroadcast(intent)
|
||||
}
|
||||
|
||||
private fun emitDevices() {
|
||||
sendBroadcast(
|
||||
Intent(ACTION_DEVICES).setPackage(packageName)
|
||||
.putParcelableArrayListExtra(EXTRA_DEVICES, ArrayList(discoveredDevices.values.map { it.toBundle() })),
|
||||
)
|
||||
}
|
||||
|
||||
private fun emitMetadata(services: List<BluetoothGattService>) {
|
||||
val serviceRows = services.map { service ->
|
||||
"${knownServiceName(service.uuid)} · ${shortUuid(service.uuid)} · ${service.characteristics.size} dados"
|
||||
}
|
||||
val characteristicRows = services.flatMap { service ->
|
||||
service.characteristics.map { characteristic ->
|
||||
"${knownCharacteristicName(characteristic.uuid)} · ${shortUuid(characteristic.uuid)} · ${propertySummary(characteristic.properties)}"
|
||||
}
|
||||
}
|
||||
sendBroadcast(
|
||||
Intent(ACTION_METADATA).setPackage(packageName)
|
||||
.putStringArrayListExtra(EXTRA_SERVICES, ArrayList(serviceRows))
|
||||
.putStringArrayListExtra(EXTRA_CHARACTERISTICS, ArrayList(characteristicRows)),
|
||||
)
|
||||
}
|
||||
|
||||
private fun emitStatus(phase: String, message: String?) {
|
||||
sendBroadcast(
|
||||
Intent(ACTION_STATUS).setPackage(packageName)
|
||||
.putExtra(EXTRA_PHASE, phase)
|
||||
.putExtra(EXTRA_STATUS_MESSAGE, message)
|
||||
.putExtra(EXTRA_SESSION_ID, activeSessionId ?: -1L)
|
||||
.putExtra(EXTRA_DEVICE_ID, currentDeviceId)
|
||||
.putExtra(EXTRA_DEVICE_NAME, currentDeviceName)
|
||||
.putExtra(EXTRA_RSSI, currentRssi ?: Int.MIN_VALUE)
|
||||
.putExtra(EXTRA_BATTERY_PERCENT, batteryPercent ?: -1)
|
||||
.putExtra(EXTRA_BODY_SENSOR_LOCATION, bodySensorLocation),
|
||||
)
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
private fun readBodyLocationOrBattery(gatt: BluetoothGatt) {
|
||||
val bodyStarted = gatt.getService(HEART_RATE_SERVICE_UUID)
|
||||
?.getCharacteristic(BODY_SENSOR_LOCATION_UUID)
|
||||
?.let { gatt.readCharacteristic(it) } == true
|
||||
if (!bodyStarted) readBattery(gatt)
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
private fun readBattery(gatt: BluetoothGatt) {
|
||||
gatt.getService(BATTERY_SERVICE_UUID)
|
||||
?.getCharacteristic(BATTERY_LEVEL_UUID)
|
||||
?.let { gatt.readCharacteristic(it) }
|
||||
}
|
||||
|
||||
private fun isHeartRateCandidate(device: NativeDiscoveredDevice): Boolean {
|
||||
val name = device.name.lowercase()
|
||||
val uuids = device.serviceUuids.map { it.lowercase() }
|
||||
return uuids.any { it == HEART_RATE_SERVICE_UUID.toString() || it.contains("180d") } ||
|
||||
name.contains("magene") ||
|
||||
name.contains("h003") ||
|
||||
name.contains("heart") ||
|
||||
name.contains("hrm")
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
private fun safeDeviceName(device: BluetoothDevice): String = runCatching {
|
||||
device.name ?: currentDeviceName ?: "Cinta cardíaca BLE"
|
||||
}.getOrDefault(currentDeviceName ?: "Cinta cardíaca BLE")
|
||||
|
||||
private fun friendlyDeviceName(name: String?): String = when {
|
||||
name.isNullOrBlank() -> "cinta cardíaca BLE"
|
||||
name.contains("h003", ignoreCase = true) || name.contains("magene", ignoreCase = true) -> "Magene H003"
|
||||
name.contains("heart", ignoreCase = true) || name.contains("hrm", ignoreCase = true) -> "cinta cardíaca BLE"
|
||||
else -> name
|
||||
}
|
||||
|
||||
private fun hasScanPermission(): Boolean {
|
||||
return Build.VERSION.SDK_INT < Build.VERSION_CODES.S ||
|
||||
ContextCompat.checkSelfPermission(this, Manifest.permission.BLUETOOTH_SCAN) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
|
||||
private fun hasConnectPermission(): Boolean {
|
||||
return Build.VERSION.SDK_INT < Build.VERSION_CODES.S ||
|
||||
ContextCompat.checkSelfPermission(this, Manifest.permission.BLUETOOTH_CONNECT) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
|
||||
private fun prefs() = getSharedPreferences(PREFS_NAME, MODE_PRIVATE)
|
||||
|
||||
private fun lastStoredRssi(): Int? {
|
||||
val value = prefs().getInt(KEY_LAST_RSSI, Int.MIN_VALUE)
|
||||
return if (value == Int.MIN_VALUE) null else value
|
||||
}
|
||||
|
||||
private fun persistState(manual: Boolean) {
|
||||
val editor = prefs().edit()
|
||||
.putBoolean(KEY_MANUAL_STOP, manual)
|
||||
.putString(KEY_LAST_DEVICE_ID, currentDeviceId)
|
||||
.putString(KEY_LAST_DEVICE_NAME, currentDeviceName)
|
||||
|
||||
currentRssi?.let { editor.putInt(KEY_LAST_RSSI, it) }
|
||||
|
||||
editor.apply()
|
||||
}
|
||||
|
||||
private fun shortUuid(uuid: UUID): String {
|
||||
val text = uuid.toString()
|
||||
return if (text.endsWith("-0000-1000-8000-00805f9b34fb")) "0x${text.substring(4, 8).uppercase()}" else text
|
||||
}
|
||||
|
||||
private fun knownServiceName(uuid: UUID): String = when (uuid) {
|
||||
HEART_RATE_SERVICE_UUID -> "Heart Rate Service"
|
||||
BATTERY_SERVICE_UUID -> "Battery Service"
|
||||
DEVICE_INFORMATION_SERVICE_UUID -> "Device Information"
|
||||
else -> "Serviço BLE"
|
||||
}
|
||||
|
||||
private fun knownCharacteristicName(uuid: UUID): String = when (uuid) {
|
||||
HEART_RATE_MEASUREMENT_UUID -> "Heart Rate Measurement"
|
||||
BODY_SENSOR_LOCATION_UUID -> "Body Sensor Location"
|
||||
BATTERY_LEVEL_UUID -> "Battery Level"
|
||||
else -> "Característica BLE"
|
||||
}
|
||||
|
||||
private fun propertySummary(properties: Int): String = buildList {
|
||||
if (properties and BluetoothGattCharacteristic.PROPERTY_READ != 0) add("read")
|
||||
if (properties and BluetoothGattCharacteristic.PROPERTY_NOTIFY != 0) add("notify")
|
||||
if (properties and BluetoothGattCharacteristic.PROPERTY_INDICATE != 0) add("indicate")
|
||||
if (properties and BluetoothGattCharacteristic.PROPERTY_WRITE != 0) add("write")
|
||||
}.ifEmpty { listOf("sem propriedade") }.joinToString("/")
|
||||
|
||||
private fun bodyLocationName(value: Int?): String? = when (value) {
|
||||
0 -> "Outro"
|
||||
1 -> "Peito"
|
||||
2 -> "Pulso"
|
||||
3 -> "Dedo"
|
||||
4 -> "Mão"
|
||||
5 -> "Orelha"
|
||||
6 -> "Pé"
|
||||
else -> null
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val ACTION_START = "com.healthtracking.fightrate.rn.START_CAPTURE"
|
||||
const val ACTION_START_MONITORING = "com.healthtracking.fightrate.rn.START_MONITORING"
|
||||
const val ACTION_CONNECT_DEVICE = "com.healthtracking.fightrate.rn.CONNECT_DEVICE"
|
||||
const val ACTION_STOP = "com.healthtracking.fightrate.rn.STOP_CAPTURE"
|
||||
const val ACTION_STOP_WINDOW = "com.healthtracking.fightrate.rn.STOP_WORKOUT_WINDOW"
|
||||
const val ACTION_SAMPLE = "com.healthtracking.fightrate.rn.SAMPLE"
|
||||
const val ACTION_STATUS = "com.healthtracking.fightrate.rn.STATUS"
|
||||
const val ACTION_DEVICES = "com.healthtracking.fightrate.rn.DEVICES"
|
||||
const val ACTION_METADATA = "com.healthtracking.fightrate.rn.METADATA"
|
||||
|
||||
const val EXTRA_DEVICE_ID = "device_id"
|
||||
const val EXTRA_DEVICE_NAME = "device_name"
|
||||
const val EXTRA_SAMPLE_ID = "sample_id"
|
||||
const val EXTRA_SESSION_ID = "session_id"
|
||||
const val EXTRA_TIMESTAMP = "timestamp"
|
||||
const val EXTRA_BPM = "bpm"
|
||||
const val EXTRA_CONTACT_DETECTED = "contact_detected"
|
||||
const val EXTRA_RR_INTERVALS = "rr_intervals"
|
||||
const val EXTRA_PHASE = "phase"
|
||||
const val EXTRA_STATUS_MESSAGE = "status_message"
|
||||
const val EXTRA_RSSI = "rssi"
|
||||
const val EXTRA_BATTERY_PERCENT = "battery_percent"
|
||||
const val EXTRA_BODY_SENSOR_LOCATION = "body_sensor_location"
|
||||
const val EXTRA_DEVICES = "devices"
|
||||
const val EXTRA_SERVICES = "services"
|
||||
const val EXTRA_CHARACTERISTICS = "characteristics"
|
||||
|
||||
const val PHASE_IDLE = "idle"
|
||||
const val PHASE_SCANNING = "scanning"
|
||||
const val PHASE_CONNECTING = "connecting"
|
||||
const val PHASE_CONNECTED = "connected"
|
||||
const val PHASE_RECEIVING = "receiving"
|
||||
const val PHASE_PERMISSION_DENIED = "permission-denied"
|
||||
const val PHASE_BLUETOOTH_OFF = "bluetooth-off"
|
||||
const val PHASE_SENSOR_NOT_FOUND = "sensor-not-found"
|
||||
const val PHASE_ERROR = "error"
|
||||
|
||||
val HEART_RATE_SERVICE_UUID: UUID = UUID.fromString("0000180d-0000-1000-8000-00805f9b34fb")
|
||||
val HEART_RATE_MEASUREMENT_UUID: UUID = UUID.fromString("00002a37-0000-1000-8000-00805f9b34fb")
|
||||
val BODY_SENSOR_LOCATION_UUID: UUID = UUID.fromString("00002a38-0000-1000-8000-00805f9b34fb")
|
||||
val BATTERY_SERVICE_UUID: UUID = UUID.fromString("0000180f-0000-1000-8000-00805f9b34fb")
|
||||
val BATTERY_LEVEL_UUID: UUID = UUID.fromString("00002a19-0000-1000-8000-00805f9b34fb")
|
||||
private val CLIENT_CHARACTERISTIC_CONFIG_UUID: UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
|
||||
private val DEVICE_INFORMATION_SERVICE_UUID: UUID = UUID.fromString("0000180a-0000-1000-8000-00805f9b34fb")
|
||||
|
||||
private const val PREFS_NAME = "fightrate_rn_capture"
|
||||
private const val KEY_MANUAL_STOP = "manual_stop"
|
||||
private const val KEY_LAST_DEVICE_ID = "last_device_id"
|
||||
private const val KEY_LAST_DEVICE_NAME = "last_device_name"
|
||||
private const val KEY_LAST_RSSI = "last_rssi"
|
||||
private const val CHANNEL_ID = "fightrate_continuous_capture"
|
||||
private const val NOTIFICATION_ID = 18037
|
||||
private const val SCAN_WINDOW_MILLIS = 20_000L
|
||||
private const val SCAN_RETRY_MILLIS = 15_000L
|
||||
private const val NOTIFICATION_THROTTLE_MILLIS = 15_000L
|
||||
private const val RSSI_READ_INTERVAL_MILLIS = 5_000L
|
||||
private const val FIRST_MEASUREMENT_TIMEOUT_MILLIS = 12_000L
|
||||
private const val VALID_SAMPLE_STALE_MILLIS = 12_000L
|
||||
private const val MIN_VALID_BPM = 30
|
||||
private const val MAX_VALID_BPM = 240
|
||||
|
||||
fun startMonitoring(context: Context, deviceId: String? = null, deviceName: String? = null) {
|
||||
val intent = Intent(context, FightRateCaptureService::class.java)
|
||||
.setAction(ACTION_START_MONITORING)
|
||||
.putExtra(EXTRA_DEVICE_ID, deviceId)
|
||||
.putExtra(EXTRA_DEVICE_NAME, deviceName)
|
||||
ContextCompat.startForegroundService(context, intent)
|
||||
}
|
||||
|
||||
fun startWorkout(context: Context, deviceId: String? = null, deviceName: String? = null) {
|
||||
start(context, deviceId, deviceName)
|
||||
}
|
||||
fun start(context: Context, deviceId: String? = null, deviceName: String? = null) {
|
||||
val intent = Intent(context, FightRateCaptureService::class.java)
|
||||
.setAction(ACTION_START)
|
||||
.putExtra(EXTRA_DEVICE_ID, deviceId)
|
||||
.putExtra(EXTRA_DEVICE_NAME, deviceName)
|
||||
ContextCompat.startForegroundService(context, intent)
|
||||
}
|
||||
|
||||
fun connect(context: Context, deviceId: String, deviceName: String?) {
|
||||
startMonitoring(context, deviceId, deviceName)
|
||||
}
|
||||
|
||||
fun stopWorkout(context: Context) {
|
||||
context.startService(Intent(context, FightRateCaptureService::class.java).setAction(ACTION_STOP_WINDOW))
|
||||
}
|
||||
|
||||
fun isMonitoringEnabled(context: Context): Boolean =
|
||||
!context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE).getBoolean(KEY_MANUAL_STOP, true)
|
||||
|
||||
fun isFreshTimestamp(timestampMillis: Long, nowMillis: Long = System.currentTimeMillis()): Boolean =
|
||||
timestampMillis > 0L && nowMillis - timestampMillis <= VALID_SAMPLE_STALE_MILLIS
|
||||
|
||||
fun lastKnownDevice(context: Context): NativeDiscoveredDevice? {
|
||||
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
val id = prefs.getString(KEY_LAST_DEVICE_ID, null)
|
||||
val name = prefs.getString(KEY_LAST_DEVICE_NAME, null)
|
||||
val rssi = prefs.getInt(KEY_LAST_RSSI, Int.MIN_VALUE)
|
||||
if (id.isNullOrBlank()) return null
|
||||
return NativeDiscoveredDevice(id, name ?: "Cinta cardíaca BLE", rssi, emptyList())
|
||||
}
|
||||
fun stop(context: Context) {
|
||||
context.startService(Intent(context, FightRateCaptureService::class.java).setAction(ACTION_STOP))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class NativeDiscoveredDevice(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val rssi: Int,
|
||||
val serviceUuids: List<String>,
|
||||
) {
|
||||
fun toBundle(): android.os.Bundle = android.os.Bundle().apply {
|
||||
putString("id", id)
|
||||
putString("name", name)
|
||||
putInt("rssi", rssi)
|
||||
putStringArrayList("serviceUuids", ArrayList(serviceUuids))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
package com.healthtracking.fightrate.capture
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import android.database.sqlite.SQLiteOpenHelper
|
||||
|
||||
class FightRateCaptureStore(context: Context) : SQLiteOpenHelper(
|
||||
context,
|
||||
DATABASE_NAME,
|
||||
null,
|
||||
DATABASE_VERSION,
|
||||
) {
|
||||
override fun onCreate(db: SQLiteDatabase) {
|
||||
createWorkoutSessionsTable(db)
|
||||
createLegacySamplesTable(db)
|
||||
createContinuousSamplesTable(db)
|
||||
}
|
||||
|
||||
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
|
||||
if (oldVersion < 2) {
|
||||
createContinuousSamplesTable(db)
|
||||
db.execSQL(
|
||||
"""
|
||||
INSERT INTO $CONTINUOUS_SAMPLE_TABLE (timestamp_millis, bpm, contact_detected, rr_intervals_ms, device_name)
|
||||
SELECT timestamp_millis, bpm, contact_detected, rr_intervals_ms, device_name
|
||||
FROM $LEGACY_SAMPLE_TABLE
|
||||
WHERE NOT EXISTS (SELECT 1 FROM $CONTINUOUS_SAMPLE_TABLE LIMIT 1)
|
||||
""".trimIndent(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun startSession(nowMillis: Long, deviceId: String?, deviceName: String?): StoredSession {
|
||||
activeSession()?.let { active ->
|
||||
updateSessionDevice(active.id, deviceId, deviceName)
|
||||
return sessionById(active.id) ?: active
|
||||
}
|
||||
|
||||
val values = ContentValues().apply {
|
||||
put("started_at_millis", nowMillis)
|
||||
putNull("ended_at_millis")
|
||||
put("status", STATUS_ACTIVE)
|
||||
put("device_id", deviceId)
|
||||
put("device_name", deviceName)
|
||||
}
|
||||
val id = writableDatabase.insert(SESSION_TABLE, null, values)
|
||||
return sessionById(id) ?: StoredSession(id, nowMillis, null, STATUS_ACTIVE, deviceId, deviceName)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun finishActiveSession(nowMillis: Long): StoredSession? {
|
||||
val active = activeSession() ?: return null
|
||||
val values = ContentValues().apply {
|
||||
put("status", STATUS_FINISHED)
|
||||
put("ended_at_millis", nowMillis)
|
||||
}
|
||||
writableDatabase.update(SESSION_TABLE, values, "id = ?", arrayOf(active.id.toString()))
|
||||
return sessionById(active.id)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun insertSample(
|
||||
activeSessionId: Long?,
|
||||
timestampMillis: Long,
|
||||
bpm: Int,
|
||||
contactDetected: Boolean?,
|
||||
rrIntervalsMs: List<Int>,
|
||||
deviceName: String?,
|
||||
): StoredSample {
|
||||
val values = ContentValues().apply {
|
||||
put("timestamp_millis", timestampMillis)
|
||||
put("bpm", bpm)
|
||||
if (contactDetected == null) putNull("contact_detected") else put("contact_detected", if (contactDetected) 1 else 0)
|
||||
put("rr_intervals_ms", rrIntervalsMs.joinToString(","))
|
||||
put("device_name", deviceName)
|
||||
}
|
||||
val id = writableDatabase.insert(CONTINUOUS_SAMPLE_TABLE, null, values)
|
||||
return StoredSample(id, activeSessionId, timestampMillis, bpm, contactDetected, rrIntervalsMs, deviceName)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun updateSessionDevice(sessionId: Long, deviceId: String?, deviceName: String?) {
|
||||
val values = ContentValues().apply {
|
||||
put("device_id", deviceId)
|
||||
put("device_name", deviceName)
|
||||
}
|
||||
writableDatabase.update(SESSION_TABLE, values, "id = ?", arrayOf(sessionId.toString()))
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun activeSession(): StoredSession? {
|
||||
val cursor = readableDatabase.query(
|
||||
SESSION_TABLE,
|
||||
SESSION_COLUMNS,
|
||||
"status = ?",
|
||||
arrayOf(STATUS_ACTIVE),
|
||||
null,
|
||||
null,
|
||||
"started_at_millis DESC",
|
||||
"1",
|
||||
)
|
||||
return cursor.use { if (it.moveToFirst()) it.toSession() else null }
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun sessionById(id: Long): StoredSession? {
|
||||
val cursor = readableDatabase.query(
|
||||
SESSION_TABLE,
|
||||
SESSION_COLUMNS,
|
||||
"id = ?",
|
||||
arrayOf(id.toString()),
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
"1",
|
||||
)
|
||||
return cursor.use { if (it.moveToFirst()) it.toSession() else null }
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun recentSamples(limit: Int): List<StoredSample> {
|
||||
val cursor = readableDatabase.query(
|
||||
CONTINUOUS_SAMPLE_TABLE,
|
||||
CONTINUOUS_SAMPLE_COLUMNS,
|
||||
VALID_SAMPLE_SELECTION,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
"timestamp_millis DESC",
|
||||
limit.toString(),
|
||||
)
|
||||
return cursor.use {
|
||||
buildList {
|
||||
while (it.moveToNext()) add(it.toContinuousSample(null))
|
||||
}.asReversed()
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun allContinuousSamples(): List<StoredSample> {
|
||||
val cursor = readableDatabase.query(
|
||||
CONTINUOUS_SAMPLE_TABLE,
|
||||
CONTINUOUS_SAMPLE_COLUMNS,
|
||||
VALID_SAMPLE_SELECTION,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
"timestamp_millis ASC",
|
||||
null,
|
||||
)
|
||||
return cursor.use {
|
||||
buildList {
|
||||
while (it.moveToNext()) add(it.toContinuousSample(null))
|
||||
}
|
||||
}
|
||||
}
|
||||
@Synchronized
|
||||
fun samplesForSession(sessionId: Long, limit: Int): List<StoredSample> {
|
||||
val session = sessionById(sessionId) ?: return emptyList()
|
||||
val endedAt = session.endedAtMillis ?: System.currentTimeMillis()
|
||||
val cursor = readableDatabase.query(
|
||||
CONTINUOUS_SAMPLE_TABLE,
|
||||
CONTINUOUS_SAMPLE_COLUMNS,
|
||||
"$VALID_SAMPLE_SELECTION AND timestamp_millis BETWEEN ? AND ?",
|
||||
arrayOf(session.startedAtMillis.toString(), endedAt.toString()),
|
||||
null,
|
||||
null,
|
||||
"timestamp_millis ASC",
|
||||
limit.toString(),
|
||||
)
|
||||
return cursor.use {
|
||||
buildList {
|
||||
while (it.moveToNext()) add(it.toContinuousSample(sessionId))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun finishedSessionSummaries(limit: Int): List<StoredSessionSummary> {
|
||||
val sql =
|
||||
"""
|
||||
SELECT s.id, s.started_at_millis, s.ended_at_millis, s.device_name,
|
||||
COUNT(hr.id) AS sample_count,
|
||||
ROUND(AVG(hr.bpm)) AS average_bpm,
|
||||
MAX(hr.bpm) AS peak_bpm,
|
||||
MIN(hr.bpm) AS min_bpm
|
||||
FROM $SESSION_TABLE s
|
||||
LEFT JOIN $CONTINUOUS_SAMPLE_TABLE hr
|
||||
ON hr.timestamp_millis BETWEEN s.started_at_millis AND COALESCE(s.ended_at_millis, strftime('%s','now') * 1000)
|
||||
AND hr.bpm BETWEEN $MIN_VALID_BPM AND $MAX_VALID_BPM
|
||||
AND (hr.contact_detected IS NULL OR hr.contact_detected = 1)
|
||||
WHERE s.status = ?
|
||||
GROUP BY s.id
|
||||
HAVING sample_count > 0
|
||||
ORDER BY s.started_at_millis DESC
|
||||
LIMIT ?
|
||||
""".trimIndent()
|
||||
val cursor = readableDatabase.rawQuery(sql, arrayOf(STATUS_FINISHED, limit.toString()))
|
||||
return cursor.use {
|
||||
buildList {
|
||||
while (it.moveToNext()) {
|
||||
val startedAt = it.getLong(1)
|
||||
val endedAt = if (it.isNull(2)) startedAt else it.getLong(2)
|
||||
add(
|
||||
StoredSessionSummary(
|
||||
id = it.getLong(0),
|
||||
startedAtMillis = startedAt,
|
||||
endedAtMillis = endedAt,
|
||||
durationMillis = (endedAt - startedAt).coerceAtLeast(0),
|
||||
deviceName = it.getString(3),
|
||||
sampleCount = it.getInt(4),
|
||||
averageBpm = it.getInt(5),
|
||||
peakBpm = it.getInt(6),
|
||||
minBpm = it.getInt(7),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun createWorkoutSessionsTable(db: SQLiteDatabase) {
|
||||
db.execSQL(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS $SESSION_TABLE (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
started_at_millis INTEGER NOT NULL,
|
||||
ended_at_millis INTEGER,
|
||||
status TEXT NOT NULL,
|
||||
device_id TEXT,
|
||||
device_name TEXT
|
||||
)
|
||||
""".trimIndent(),
|
||||
)
|
||||
db.execSQL("CREATE INDEX IF NOT EXISTS idx_sessions_status_start ON $SESSION_TABLE(status, started_at_millis)")
|
||||
}
|
||||
|
||||
private fun createLegacySamplesTable(db: SQLiteDatabase) {
|
||||
db.execSQL(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS $LEGACY_SAMPLE_TABLE (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id INTEGER NOT NULL,
|
||||
timestamp_millis INTEGER NOT NULL,
|
||||
bpm INTEGER NOT NULL,
|
||||
contact_detected INTEGER,
|
||||
rr_intervals_ms TEXT NOT NULL DEFAULT '',
|
||||
device_name TEXT,
|
||||
FOREIGN KEY(session_id) REFERENCES $SESSION_TABLE(id)
|
||||
)
|
||||
""".trimIndent(),
|
||||
)
|
||||
db.execSQL("CREATE INDEX IF NOT EXISTS idx_samples_session_time ON $LEGACY_SAMPLE_TABLE(session_id, timestamp_millis)")
|
||||
}
|
||||
|
||||
private fun createContinuousSamplesTable(db: SQLiteDatabase) {
|
||||
db.execSQL(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS $CONTINUOUS_SAMPLE_TABLE (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
timestamp_millis INTEGER NOT NULL,
|
||||
bpm INTEGER NOT NULL,
|
||||
contact_detected INTEGER,
|
||||
rr_intervals_ms TEXT NOT NULL DEFAULT '',
|
||||
device_name TEXT
|
||||
)
|
||||
""".trimIndent(),
|
||||
)
|
||||
db.execSQL("CREATE INDEX IF NOT EXISTS idx_continuous_samples_time ON $CONTINUOUS_SAMPLE_TABLE(timestamp_millis)")
|
||||
}
|
||||
|
||||
private fun android.database.Cursor.toSession(): StoredSession = StoredSession(
|
||||
id = getLong(0),
|
||||
startedAtMillis = getLong(1),
|
||||
endedAtMillis = if (isNull(2)) null else getLong(2),
|
||||
status = getString(3),
|
||||
deviceId = getString(4),
|
||||
deviceName = getString(5),
|
||||
)
|
||||
|
||||
private fun android.database.Cursor.toContinuousSample(sessionId: Long?): StoredSample = StoredSample(
|
||||
id = getLong(0),
|
||||
sessionId = sessionId,
|
||||
timestampMillis = getLong(1),
|
||||
bpm = getInt(2),
|
||||
contactDetected = if (isNull(3)) null else getInt(3) == 1,
|
||||
rrIntervalsMs = getString(4).orEmpty().split(',').mapNotNull { it.toIntOrNull() },
|
||||
deviceName = getString(5),
|
||||
)
|
||||
|
||||
companion object {
|
||||
private const val DATABASE_NAME = "fightrate_rn_capture.db"
|
||||
private const val DATABASE_VERSION = 2
|
||||
private const val SESSION_TABLE = "workout_sessions"
|
||||
private const val LEGACY_SAMPLE_TABLE = "heart_rate_samples"
|
||||
private const val CONTINUOUS_SAMPLE_TABLE = "continuous_heart_rate_samples"
|
||||
const val STATUS_ACTIVE = "active"
|
||||
const val STATUS_FINISHED = "finished"
|
||||
private val SESSION_COLUMNS = arrayOf("id", "started_at_millis", "ended_at_millis", "status", "device_id", "device_name")
|
||||
private val CONTINUOUS_SAMPLE_COLUMNS = arrayOf("id", "timestamp_millis", "bpm", "contact_detected", "rr_intervals_ms", "device_name")
|
||||
private const val MIN_VALID_BPM = 30
|
||||
private const val MAX_VALID_BPM = 240
|
||||
private const val VALID_SAMPLE_SELECTION = "bpm BETWEEN $MIN_VALID_BPM AND $MAX_VALID_BPM AND (contact_detected IS NULL OR contact_detected = 1)"
|
||||
}
|
||||
}
|
||||
|
||||
data class StoredSession(
|
||||
val id: Long,
|
||||
val startedAtMillis: Long,
|
||||
val endedAtMillis: Long?,
|
||||
val status: String,
|
||||
val deviceId: String?,
|
||||
val deviceName: String?,
|
||||
)
|
||||
|
||||
data class StoredSample(
|
||||
val id: Long,
|
||||
val sessionId: Long?,
|
||||
val timestampMillis: Long,
|
||||
val bpm: Int,
|
||||
val contactDetected: Boolean?,
|
||||
val rrIntervalsMs: List<Int>,
|
||||
val deviceName: String?,
|
||||
)
|
||||
|
||||
data class StoredSessionSummary(
|
||||
val id: Long,
|
||||
val startedAtMillis: Long,
|
||||
val endedAtMillis: Long,
|
||||
val durationMillis: Long,
|
||||
val deviceName: String?,
|
||||
val sampleCount: Int,
|
||||
val averageBpm: Int,
|
||||
val peakBpm: Int,
|
||||
val minBpm: Int,
|
||||
)
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.healthtracking.fightrate.capture
|
||||
|
||||
import com.facebook.react.ReactPackage
|
||||
import com.facebook.react.bridge.NativeModule
|
||||
import com.facebook.react.bridge.ReactApplicationContext
|
||||
import com.facebook.react.uimanager.ViewManager
|
||||
|
||||
class FightRateNativePackage : ReactPackage {
|
||||
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> =
|
||||
listOf(FightRateCaptureModule(reactContext))
|
||||
|
||||
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<in Nothing, in Nothing>> =
|
||||
emptyList()
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.healthtracking.fightrate.capture
|
||||
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
|
||||
data class ParsedHeartRateMeasurement(
|
||||
val bpm: Int,
|
||||
val contactDetected: Boolean?,
|
||||
val rrIntervalsMs: List<Int>,
|
||||
)
|
||||
|
||||
object HeartRateMeasurementParser {
|
||||
fun parse(value: ByteArray): ParsedHeartRateMeasurement {
|
||||
require(value.size >= 2) { "Pacote de batimento incompleto" }
|
||||
|
||||
val flags = value[0].toInt() and 0xFF
|
||||
val isUInt16 = flags and 0x01 != 0
|
||||
val contactSupported = flags and 0x04 != 0
|
||||
val contactDetected = if (contactSupported) flags and 0x02 != 0 else null
|
||||
val hasRrIntervals = flags and 0x10 != 0
|
||||
val hasEnergyExpended = flags and 0x08 != 0
|
||||
|
||||
var offset = 1
|
||||
val bpm = if (isUInt16) {
|
||||
require(value.size >= 3) { "Pacote de batimento 16-bit incompleto" }
|
||||
val parsed = ByteBuffer.wrap(value, offset, 2).order(ByteOrder.LITTLE_ENDIAN).short.toInt() and 0xFFFF
|
||||
offset += 2
|
||||
parsed
|
||||
} else {
|
||||
val parsed = value[offset].toInt() and 0xFF
|
||||
offset += 1
|
||||
parsed
|
||||
}
|
||||
|
||||
if (hasEnergyExpended) {
|
||||
require(value.size >= offset + 2) { "Pacote de energia expended incompleto" }
|
||||
offset += 2
|
||||
}
|
||||
|
||||
val rrIntervalsMs = mutableListOf<Int>()
|
||||
if (hasRrIntervals) {
|
||||
while (offset + 1 < value.size) {
|
||||
val rrRaw = ByteBuffer.wrap(value, offset, 2).order(ByteOrder.LITTLE_ENDIAN).short.toInt() and 0xFFFF
|
||||
rrIntervalsMs.add(((rrRaw * 1000.0) / 1024.0).toInt())
|
||||
offset += 2
|
||||
}
|
||||
}
|
||||
|
||||
return ParsedHeartRateMeasurement(
|
||||
bpm = bpm,
|
||||
contactDetected = contactDetected,
|
||||
rrIntervalsMs = rrIntervalsMs,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 4.9 KiB After Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 4.5 KiB After Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 6.9 KiB After Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 6.3 KiB After Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 9.0 KiB After Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 1.3 MiB |
@@ -1,3 +1,3 @@
|
||||
<resources>
|
||||
<string name="app_name">FightRateRN</string>
|
||||
<string name="app_name">FightRate</string>
|
||||
</resources>
|
||||