Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 46 additions & 3 deletions wear/src/main/kotlin/app/aaps/wear/heartrate/HeartRateListener.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,13 @@ package app.aaps.wear.heartrate

import android.content.Context
import android.content.Context.SENSOR_SERVICE
import android.content.Intent
import android.content.IntentFilter
import android.hardware.Sensor
import android.hardware.SensorEvent
import android.hardware.SensorEventListener
import android.hardware.SensorManager
import android.os.BatteryManager
import android.os.Build
import androidx.annotation.VisibleForTesting
import app.aaps.core.interfaces.logging.AAPSLogger
Expand Down Expand Up @@ -52,6 +55,7 @@ class HeartRateListener(
/** How often we send values to the phone. */
private val samplingIntervalMillis = 60_000L
private val sampler = Sampler(now, sp)
private var offBody = false
private var schedule: Disposable? = null

/** We only use values with these accuracies and ignore NO_CONTACT and UNRELIABLE. */
Expand All @@ -73,6 +77,13 @@ class HeartRateListener(
} else {
sensorManager.registerListener(this, heartRateSensor, SensorManager.SENSOR_DELAY_NORMAL)
}

val offBodySensor = sensorManager.getDefaultSensor(Sensor.TYPE_LOW_LATENCY_OFFBODY_DETECT)
if (offBodySensor == null) {
aapsLogger.warn(LTag.WEAR, "Cannot get off body sensor")
} else {
sensorManager.registerListener(this, offBodySensor, SensorManager.SENSOR_DELAY_NORMAL)
}
}
schedule = aapsSchedulers.io.schedulePeriodicallyDirect(
::send, samplingIntervalMillis, samplingIntervalMillis, TimeUnit.MILLISECONDS
Expand Down Expand Up @@ -101,6 +112,26 @@ class HeartRateListener(
send(System.currentTimeMillis())
}

fun isDeviceCharging(context: Context): Boolean {
val batteryStatus: Intent? = context.registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED))
val status = batteryStatus?.getIntExtra(BatteryManager.EXTRA_STATUS, -1) ?: -1

val chargePlug = batteryStatus?.getIntExtra(BatteryManager.EXTRA_PLUGGED, -1) ?: -1

val isCharging = status == BatteryManager.BATTERY_STATUS_CHARGING ||
chargePlug == BatteryManager.BATTERY_PLUGGED_AC ||
chargePlug == BatteryManager.BATTERY_PLUGGED_USB ||
chargePlug == BatteryManager.BATTERY_PLUGGED_WIRELESS

if (isCharging) {
sampler.setHeartRate(System.currentTimeMillis(), null)
}

return isCharging
}



@VisibleForTesting
fun send(timestampMillis: Long) {
sampler.getAndReset(timestampMillis)?.let { hr ->
Expand Down Expand Up @@ -128,12 +159,24 @@ class HeartRateListener(

@VisibleForTesting
fun onSensorChanged(sensorType: Int?, accuracy: Int, timestampMillis: Long, values: FloatArray) {
if (sensorType == null || sensorType != Sensor.TYPE_HEART_RATE || values.isEmpty()) {

if (sensorType == null || values.isEmpty()) {
aapsLogger.error(LTag.WEAR, "Invalid SensorEvent $sensorType $accuracy $timestampMillis ${values.joinToString()}")
return
}
val heartRate = values[0].toDouble().takeIf { accuracy in goodAccuracies }
sampler.setHeartRate(timestampMillis, heartRate)
if (sensorType == Sensor.TYPE_LOW_LATENCY_OFFBODY_DETECT) {
val offBodyDataFloat = values[0];
offBody = offBodyDataFloat.roundToInt() == 0;
if(offBody) {
sampler.setHeartRate(timestampMillis, null)
}
return
}

if (sensorType == Sensor.TYPE_HEART_RATE && !offBody && !isDeviceCharging(ctx)) {
val heartRate = values[0].toDouble().takeIf { accuracy in goodAccuracies }
sampler.setHeartRate(timestampMillis, heartRate)
}
}

private class Sampler(timestampMillis: Long, val sp: SP) {
Expand Down
174 changes: 173 additions & 1 deletion wear/src/test/kotlin/app/aaps/wear/heartrate/HeartRateListenerTest.kt
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
package app.aaps.wear.heartrate

import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.hardware.Sensor
import android.hardware.SensorManager
import android.os.BatteryManager
import app.aaps.core.interfaces.rx.AapsSchedulers
import app.aaps.core.interfaces.rx.weardata.EventData.ActionHeartRate
import app.aaps.core.interfaces.sharedPreferences.SP
Expand All @@ -15,6 +18,7 @@ import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.mockito.ArgumentMatchers.any
import org.mockito.ArgumentMatchers.anyInt
import org.mockito.ArgumentMatchers.eq
import org.mockito.kotlin.mock
import org.mockito.kotlin.verify
Expand All @@ -36,9 +40,10 @@ internal class HeartRateListenerTest {
private val sp: SP = mock()
private val heartRates = mutableListOf<ActionHeartRate>()
private val device = "unknown unknown"
private lateinit var ctx: Context

private fun create(timestampMillis: Long): HeartRateListener {
val ctx: Context = mock()
ctx = mock()
whenever(
aapsSchedulers.io.schedulePeriodicallyDirect(
any(), eq(60_000L), eq(60_000L), eq(TimeUnit.MILLISECONDS)
Expand Down Expand Up @@ -172,4 +177,171 @@ internal class HeartRateListenerTest {
listener.dispose()
}

@Test
fun onOffBodyDetected_shouldNotRecordHeartRate() {
whenever(sp.getInt(R.string.key_heart_rate_smoothing, 1)).thenReturn(1)
val start = System.currentTimeMillis()
val d1 = 10_000L
val d2 = 20_000L
val listener = create(start)

// Send heart rate while on body
sendSensorEvent(listener, start, 80)
assertThat(listener.currentHeartRateBpm).isEqualTo(80)

// Simulate off-body detection (value 0 = not on body)
listener.onSensorChanged(
Sensor.TYPE_LOW_LATENCY_OFFBODY_DETECT,
SensorManager.SENSOR_STATUS_ACCURACY_HIGH,
start + d1,
floatArrayOf(0f)
)

// Heart rate should be cleared when off body
assertThat(listener.currentHeartRateBpm).isNull()

// Send heart rate while off body - should be ignored
sendSensorEvent(listener, start + d1, 90)
assertThat(listener.currentHeartRateBpm).isNull()

listener.send(start + d2)
// No heart rate should be sent since device is off body
assertThat(heartRates).isEmpty()
listener.dispose()
}

@Test
fun onOnBodyDetected_shouldResumeRecordingHeartRate() {
whenever(sp.getInt(R.string.key_heart_rate_smoothing, 1)).thenReturn(1)
val start = System.currentTimeMillis()
val d1 = 10_000L
val d2 = 20_000L
val d3 = 40_000L
val listener = create(start)

// Start on body
sendSensorEvent(listener, start, 80)
assertThat(listener.currentHeartRateBpm).isEqualTo(80)

// Go off body
listener.onSensorChanged(
Sensor.TYPE_LOW_LATENCY_OFFBODY_DETECT,
SensorManager.SENSOR_STATUS_ACCURACY_HIGH,
start + d1,
floatArrayOf(0f)
)
assertThat(listener.currentHeartRateBpm).isNull()

// Come back on body (value 1 = on body)
listener.onSensorChanged(
Sensor.TYPE_LOW_LATENCY_OFFBODY_DETECT,
SensorManager.SENSOR_STATUS_ACCURACY_HIGH,
start + d2,
floatArrayOf(1f)
)

// Now send heart rate - should be recorded
sendSensorEvent(listener, start + d2, 85)
assertThat(listener.currentHeartRateBpm).isEqualTo(85)

listener.send(start + d3)
assertThat(heartRates).hasSize(1)
assertThat(heartRates[0].beatsPerMinute).isEqualTo(85.0)
listener.dispose()
}

@Test
fun onDeviceCharging_shouldNotRecordHeartRate() {
whenever(sp.getInt(R.string.key_heart_rate_smoothing, 1)).thenReturn(1)
val start = System.currentTimeMillis()
val d1 = 10_000L
val d2 = 20_000L
val listener = create(start)

// Simulate device is charging
val chargingIntent = Intent().apply {
putExtra(BatteryManager.EXTRA_STATUS, BatteryManager.BATTERY_STATUS_CHARGING)
}
whenever(ctx.registerReceiver(any(), any<IntentFilter>())).thenReturn(chargingIntent)

// Send heart rate while charging
sendSensorEvent(listener, start, 80)
assertThat(listener.currentHeartRateBpm).isNull()

listener.send(start + d2)
// No heart rate should be sent since device is charging
assertThat(heartRates).isEmpty()
listener.dispose()
}

@Test
fun onDeviceNotCharging_shouldRecordHeartRate() {
whenever(sp.getInt(R.string.key_heart_rate_smoothing, 1)).thenReturn(1)
val start = System.currentTimeMillis()
val d1 = 10_000L
val d2 = 20_000L
val listener = create(start)

// Simulate device is not charging
val notChargingIntent = Intent().apply {
putExtra(BatteryManager.EXTRA_STATUS, BatteryManager.BATTERY_STATUS_DISCHARGING)
}
whenever(ctx.registerReceiver(any(), any<IntentFilter>())).thenReturn(notChargingIntent)

// Send heart rate while not charging - should be recorded
sendSensorEvent(listener, start, 80)
assertThat(listener.currentHeartRateBpm).isEqualTo(80)

listener.send(start + d2)
assertThat(heartRates).hasSize(1)
assertThat(heartRates[0].beatsPerMinute).isEqualTo(80.0)
listener.dispose()
}

@Test
fun isDeviceCharging_acUsbWireless_shouldReturnTrue() {
whenever(sp.getInt(R.string.key_heart_rate_smoothing, 1)).thenReturn(1)
val listener = create(System.currentTimeMillis())

// Test AC charging
val acIntent = Intent().apply {
putExtra(BatteryManager.EXTRA_STATUS, BatteryManager.BATTERY_STATUS_CHARGING)
putExtra(BatteryManager.EXTRA_PLUGGED, BatteryManager.BATTERY_PLUGGED_AC)
}
whenever(ctx.registerReceiver(any(), any<IntentFilter>())).thenReturn(acIntent)
assertThat(listener.isDeviceCharging(ctx)).isTrue()

// Test USB charging
val usbIntent = Intent().apply {
putExtra(BatteryManager.EXTRA_STATUS, BatteryManager.BATTERY_STATUS_CHARGING)
putExtra(BatteryManager.EXTRA_PLUGGED, BatteryManager.BATTERY_PLUGGED_USB)
}
whenever(ctx.registerReceiver(any(), any<IntentFilter>())).thenReturn(usbIntent)
assertThat(listener.isDeviceCharging(ctx)).isTrue()

// Test Wireless charging
val wirelessIntent = Intent().apply {
putExtra(BatteryManager.EXTRA_STATUS, BatteryManager.BATTERY_STATUS_CHARGING)
putExtra(BatteryManager.EXTRA_PLUGGED, BatteryManager.BATTERY_PLUGGED_WIRELESS)
}
whenever(ctx.registerReceiver(any(), any<IntentFilter>())).thenReturn(wirelessIntent)
assertThat(listener.isDeviceCharging(ctx)).isTrue()

listener.dispose()
}

@Test
fun isDeviceCharging_notCharging_shouldReturnFalse() {
whenever(sp.getInt(R.string.key_heart_rate_smoothing, 1)).thenReturn(1)
val listener = create(System.currentTimeMillis())

val dischargingIntent = Intent().apply {
putExtra(BatteryManager.EXTRA_STATUS, BatteryManager.BATTERY_STATUS_DISCHARGING)
}
whenever(ctx.registerReceiver(any(), any<IntentFilter>())).thenReturn(dischargingIntent)
assertThat(listener.isDeviceCharging(ctx)).isFalse()

listener.dispose()
}

}