Implement Health Connect Sensors (#4452)

* Implement Health Connect Sensors

* Fix crashes on device below SDK 34

* Minor code changes

* Fix issues causing github actions fail

* Fix issues causing github actions fail

* Add entity category, type and precision to 2 decimals

* Change to fullimplementation

* Change names to sentence case

* Revert fullimplementation

* Redirect to privacy policy for Health Connect permissions
This commit is contained in:
Prasad Bankar 2024-08-05 12:02:04 -04:00 committed by GitHub
parent 50808ff98a
commit 485a842718
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 243 additions and 4 deletions

View file

@ -122,6 +122,7 @@ dependencies {
coreLibraryDesugaring(libs.tools.desugar.jdk)
implementation(libs.blurView)
implementation(libs.androidx.health.connect.client)
implementation(libs.kotlin.stdlib)
implementation(libs.kotlin.reflect)

View file

@ -5,11 +5,15 @@
<uses-sdk tools:overrideLibrary="com.google.android.gms.threadnetwork" />
<uses-permission android:name="com.google.android.gms.permission.CAR_FUEL" />
<uses-permission android:name="com.google.android.gms.permission.CAR_MILEAGE" />
<uses-permission android:name="android.permission.health.READ_ACTIVE_CALORIES_BURNED" />
<uses-permission android:name="android.permission.health.READ_TOTAL_CALORIES_BURNED" />
<uses-permission android:name="android.permission.health.READ_WEIGHT" />
<queries>
<!-- For GMS Core/Play service -->
<package android:name="com.google.android.gms" />
<package android:name="com.android.vending" />
<package android:name="com.google.android.apps.healthdata" />
<!-- End of GMS Core/Play service-->
</queries>

View file

@ -0,0 +1,178 @@
package io.homeassistant.companion.android.sensors
import android.content.Context
import android.os.Build
import android.os.Build.VERSION.SDK_INT
import androidx.health.connect.client.HealthConnectClient
import androidx.health.connect.client.permission.HealthPermission
import androidx.health.connect.client.records.ActiveCaloriesBurnedRecord
import androidx.health.connect.client.records.TotalCaloriesBurnedRecord
import androidx.health.connect.client.records.WeightRecord
import androidx.health.connect.client.request.AggregateRequest
import androidx.health.connect.client.request.ReadRecordsRequest
import androidx.health.connect.client.time.TimeRangeFilter
import io.homeassistant.companion.android.common.R as commonR
import io.homeassistant.companion.android.common.sensors.SensorManager
import java.math.BigDecimal
import java.math.RoundingMode
import java.time.Instant
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.LocalTime
import java.time.ZoneOffset
import java.time.temporal.ChronoUnit
import kotlinx.coroutines.runBlocking
class HealthConnectSensorManager : SensorManager {
companion object {
val activeCaloriesBurned = SensorManager.BasicSensor(
id = "health_connect_active_calories_burned",
type = "sensor",
commonR.string.basic_sensor_name_active_calories_burned,
commonR.string.sensor_description_active_calories_burned,
"mdi:fire",
unitOfMeasurement = "kcal",
entityCategory = SensorManager.ENTITY_CATEGORY_DIAGNOSTIC,
updateType = SensorManager.BasicSensor.UpdateType.WORKER
)
val totalCaloriesBurned = SensorManager.BasicSensor(
id = "health_connect_total_calories_burned",
type = "sensor",
commonR.string.basic_sensor_name_total_calories_burned,
commonR.string.sensor_description_total_calories_burned,
"mdi:fire",
unitOfMeasurement = "kcal",
entityCategory = SensorManager.ENTITY_CATEGORY_DIAGNOSTIC,
updateType = SensorManager.BasicSensor.UpdateType.WORKER
)
val weight = SensorManager.BasicSensor(
id = "health_connect_weight",
type = "sensor",
commonR.string.basic_sensor_name_weight,
commonR.string.sensor_description_weight,
"mdi:weight",
unitOfMeasurement = "g",
entityCategory = SensorManager.ENTITY_CATEGORY_DIAGNOSTIC,
updateType = SensorManager.BasicSensor.UpdateType.WORKER,
deviceClass = "weight"
)
}
override val name: Int
get() = commonR.string.sensor_name_health_connect
override fun requiredPermissions(sensorId: String): Array<String> {
return when {
(sensorId == activeCaloriesBurned.id) -> arrayOf(HealthPermission.getReadPermission(ActiveCaloriesBurnedRecord::class))
(sensorId == totalCaloriesBurned.id) -> arrayOf(HealthPermission.getReadPermission(TotalCaloriesBurnedRecord::class))
(sensorId == weight.id) -> arrayOf(HealthPermission.getReadPermission(WeightRecord::class))
else -> arrayOf()
}
}
override fun requestSensorUpdate(context: Context) {
val healthConnectClient: HealthConnectClient = HealthConnectClient.getOrCreate(context)
if (isEnabled(context, weight)) {
updateWeightSensor(context, healthConnectClient)
}
if (isEnabled(context, activeCaloriesBurned)) {
updateActiveCaloriesBurnedSensor(context, healthConnectClient)
}
if (isEnabled(context, totalCaloriesBurned)) {
updateTotalCaloriesBurnedSensor(context, healthConnectClient)
}
}
private fun updateTotalCaloriesBurnedSensor(context: Context, healthConnectClient: HealthConnectClient) {
val totalCaloriesBurnedRequest = runBlocking {
healthConnectClient.aggregate(
AggregateRequest(
metrics = setOf(TotalCaloriesBurnedRecord.ENERGY_TOTAL),
timeRangeFilter = TimeRangeFilter.between(
LocalDateTime.of(LocalDate.now(), LocalTime.MIDNIGHT),
LocalDateTime.of(LocalDate.now(), LocalTime.now())
)
)
)
}
totalCaloriesBurnedRequest[TotalCaloriesBurnedRecord.ENERGY_TOTAL]?.let {
onSensorUpdated(
context,
totalCaloriesBurned,
BigDecimal(it.inKilocalories).setScale(2, RoundingMode.HALF_EVEN),
totalCaloriesBurned.statelessIcon,
attributes = mapOf("endTime" to LocalDateTime.of(LocalDate.now(), LocalTime.now()).toInstant(ZoneOffset.UTC))
)
}
}
private fun updateWeightSensor(context: Context, healthConnectClient: HealthConnectClient) {
val weightRequest = ReadRecordsRequest(
recordType = WeightRecord::class,
timeRangeFilter = TimeRangeFilter.between(
Instant.now().minus(30, ChronoUnit.DAYS),
Instant.now()
),
ascendingOrder = false,
pageSize = 1
)
val response = runBlocking { healthConnectClient.readRecords(weightRequest) }
onSensorUpdated(
context,
weight,
BigDecimal(response.records.last().weight.inGrams).setScale(2, RoundingMode.HALF_EVEN),
weight.statelessIcon,
attributes = mapOf("date" to response.records.last().time)
)
}
private fun updateActiveCaloriesBurnedSensor(context: Context, healthConnectClient: HealthConnectClient) {
val activeCaloriesBurnedRequest = ReadRecordsRequest(
recordType = ActiveCaloriesBurnedRecord::class,
timeRangeFilter = TimeRangeFilter.between(
Instant.now().minus(30, ChronoUnit.DAYS),
Instant.now()
),
ascendingOrder = false,
pageSize = 1
)
val response = runBlocking { healthConnectClient.readRecords(activeCaloriesBurnedRequest) }
if (response.records.isEmpty()) {
return
}
onSensorUpdated(
context,
activeCaloriesBurned,
BigDecimal(response.records.last().energy.inKilocalories).setScale(2, RoundingMode.HALF_EVEN),
activeCaloriesBurned.statelessIcon,
attributes = mapOf("endTime" to response.records.last().endTime)
)
}
override suspend fun getAvailableSensors(context: Context): List<SensorManager.BasicSensor> {
return if (hasSensor(context)) {
listOf(weight, activeCaloriesBurned, totalCaloriesBurned)
} else {
emptyList()
}
}
override fun hasSensor(context: Context): Boolean {
return SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE
}
override fun checkPermission(context: Context, sensorId: String): Boolean {
val healthConnectClient = HealthConnectClient.getOrCreate(context)
val result = runBlocking {
healthConnectClient.permissionController.getGrantedPermissions().containsAll(requiredPermissions(sensorId).toSet())
}
return result
}
}

View file

@ -2,7 +2,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-sdk tools:overrideLibrary="androidx.wear.remote.interactions,androidx.car.app.projected" />
<uses-sdk tools:overrideLibrary="androidx.wear.remote.interactions,androidx.car.app.projected, androidx.health.connect.client" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
@ -100,6 +100,7 @@
<action android:name="android.bluetooth.device.action.ACL_CONNECTED" />
<action android:name="android.bluetooth.device.action.ACL_DISCONNECTED" />
<action android:name="io.homeassistant.companion.android.UPDATE_SENSORS" />
<action android:name="androidx.health.ACTION_SHOW_PERMISSIONS_RATIONALE" />
</intent-filter>
</receiver>
@ -275,6 +276,7 @@
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
@ -348,9 +350,15 @@
<activity
android:name=".settings.SettingsActivity"
android:parentActivityName=".webview.WebViewActivity"
android:exported="false"
android:exported="true"
android:configChanges="orientation|screenSize"
android:theme="@style/Theme.HomeAssistant.Config"/>
android:theme="@style/Theme.HomeAssistant.Config">
<intent-filter>
<action android:name="android.intent.action.VIEW_PERMISSION_USAGE" />
<category android:name="android.intent.category.HEALTH_PERMISSIONS" />
</intent-filter>
</activity>
<activity
android:name=".nfc.TagReaderActivity"

View file

@ -66,6 +66,7 @@ class SensorReceiver : SensorReceiverBase() {
DynamicColorSensorManager(),
DevicePolicyManager(),
GeocodeSensorManager(),
HealthConnectSensorManager(),
KeyguardSensorManager(),
LastAppSensorManager(),
LastRebootSensorManager(),

View file

@ -2,6 +2,7 @@ package io.homeassistant.companion.android.settings
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.util.Log
import android.view.MenuItem
@ -48,6 +49,12 @@ class SettingsActivity : BaseActivity() {
}
override fun onCreate(savedInstanceState: Bundle?) {
if (intent.action == "android.intent.action.VIEW_PERMISSION_USAGE") {
Intent(Intent.ACTION_VIEW, Uri.parse(resources.getString(commonR.string.privacy_url))).also {
startActivity(it)
}
finish()
}
val entryPoint = EntryPointAccessors.fromActivity(this, SettingsFragmentFactoryEntryPoint::class.java)
supportFragmentManager.fragmentFactory = entryPoint.getSettingsFragmentFactory()

View file

@ -3,6 +3,7 @@ package io.homeassistant.companion.android.settings.sensor.views
import android.content.Intent
import android.net.Uri
import android.provider.Settings
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.background
@ -64,6 +65,7 @@ import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.health.connect.client.PermissionController
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
import com.mikepenz.iconics.IconicsDrawable
@ -75,6 +77,7 @@ import io.homeassistant.companion.android.database.sensor.SensorSetting
import io.homeassistant.companion.android.database.sensor.SensorSettingType
import io.homeassistant.companion.android.database.sensor.SensorWithAttributes
import io.homeassistant.companion.android.database.settings.SensorUpdateFrequencySetting
import io.homeassistant.companion.android.sensors.HealthConnectSensorManager
import io.homeassistant.companion.android.settings.sensor.SensorDetailViewModel
import io.homeassistant.companion.android.util.compose.MdcAlertDialog
import io.homeassistant.companion.android.util.compose.TransparentChip
@ -92,6 +95,8 @@ fun SensorDetailView(
val context = LocalContext.current
var sensorUpdateTypeInfo by remember { mutableStateOf(false) }
val jsonMapper by lazy { jacksonObjectMapper() }
val healthConnectPermission = rememberLauncherForActivityResult(PermissionController.createRequestPermissionResultContract(context.packageName)) {
}
val sensorEnabled = viewModel.sensor?.sensor?.enabled
?: (
@ -107,7 +112,11 @@ fun SensorDetailView(
).let { result ->
if (result == SnackbarResult.ActionPerformed) {
if (it.actionOpensSettings) {
context.startActivity(Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.parse("package:${context.packageName}")))
if (viewModel.sensorId.startsWith("health_connect")) {
healthConnectPermission.launch(HealthConnectSensorManager().requiredPermissions(viewModel.sensorId).toSet())
} else {
context.startActivity(Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.parse("package:${context.packageName}")))
}
} else {
onSetEnabled(true, it.serverId)
}

View file

@ -0,0 +1,21 @@
package io.homeassistant.companion.android.sensors
import android.content.Context
import io.homeassistant.companion.android.common.R
import io.homeassistant.companion.android.common.sensors.SensorManager
class HealthConnectSensorManager : SensorManager {
override val name: Int
get() = R.string.sensor_name_health_connect
override fun requiredPermissions(sensorId: String): Array<String> {
return emptyArray()
}
override fun requestSensorUpdate(context: Context) {
}
override suspend fun getAvailableSensors(context: Context): List<SensorManager.BasicSensor> {
return listOf()
}
}

View file

@ -151,6 +151,7 @@ dependencies {
coreLibraryDesugaring(libs.tools.desugar.jdk)
implementation(libs.blurView)
implementation(libs.androidx.health.connect.client)
implementation(libs.kotlin.stdlib)
implementation(libs.kotlin.reflect)

View file

@ -44,6 +44,7 @@
<string name="background_access_enabled">Home Assistant has access to run in the background</string>
<string name="background_access_title">Background access</string>
<string name="basic_sensor_lullaby">Lullaby</string>
<string name="basic_sensor_name_active_calories_burned">Active calories burned</string>
<string name="basic_sensor_name_active_notification_count">Active notification count</string>
<string name="basic_sensor_name_activity">Detected activity</string>
<string name="basic_sensor_name_alarm">Next alarm</string>
@ -105,8 +106,10 @@
<string name="basic_sensor_name_sim2">SIM 2</string>
<string name="basic_sensor_name_sleep_confidence">Sleep confidence</string>
<string name="basic_sensor_name_sleep_segment">Sleep segment</string>
<string name="basic_sensor_name_total_calories_burned">Total calories burned</string>
<string name="basic_sensor_name_total_rx_gb">Total Rx GB</string>
<string name="basic_sensor_name_total_tx_gb">Total Tx GB</string>
<string name="basic_sensor_name_weight">Weight</string>
<string name="basic_sensor_name_wifi_bssid">WiFi BSSID</string>
<string name="basic_sensor_name_wifi_frequency">WiFi frequency</string>
<string name="basic_sensor_name_wifi_ip">WiFi IP address</string>
@ -560,6 +563,7 @@
<string name="select_entity_to_display">Select entity to display</string>
<string name="select_file">Select file</string>
<string name="select_instance">Select your Home Assistant server</string>
<string name="sensor_description_active_calories_burned">Get active calories burned from Health Connect</string>
<string name="sensor_description_active_notification_count">Total count of active notifications that are visible to the user including silent, persistent and the Sensor Worker notifications. Attributes will include the contents of all notifications if the corresponding setting is enabled. It\'s enabled by default.</string>
<string name="sensor_description_app_importance">If the app is in the foreground, background or any other state it can be</string>
<string name="sensor_description_android_auto">If the phone is currently connected to an Android Auto head unit</string>
@ -636,12 +640,14 @@
<string name="sensor_description_speakerphone">Whether speakerphone is enabled on the device</string>
<string name="sensor_description_steps_sensor">The total number of steps since the last reboot of the device</string>
<string name="sensor_description_storage_sensor">Information about the total and available storage space internally and externally</string>
<string name="sensor_description_total_calories_burned">Get total calories burned from Health Connect</string>
<string name="sensor_description_total_rx_gb">Total Rx GB since last device reboot</string>
<string name="sensor_description_total_tx_gb">Total Tx GB since last device reboot</string>
<string name="sensor_description_volume_alarm">Volume level for alarms on the device</string>
<string name="sensor_description_volume_call">Volume level for calls on the device</string>
<string name="sensor_description_volume_music">Volume level for music on the device</string>
<string name="sensor_description_volume_ring">Volume level for the ringer on the device</string>
<string name="sensor_description_weight">Last recorded weight in Health Connect</string>
<string name="sensor_description_wifi_bssid">The mac address of the currently connected WiFi access point. Enabling the \"Get current BSSID\" toggle will create a new setting allowing you to rename the state sent back to Home Assistant for the chosen mac address. The toggle will default to off after the current BSSID is stored, so you will need to enable it once for each BSSID you wish to rename. You can also clear out the value to remove the setting in the next update.</string>
<string name="sensor_description_wifi_connection">The name of the network the device is currently connected to</string>
<string name="sensor_description_wifi_frequency">The frequency band of the connected network</string>
@ -1267,4 +1273,5 @@
<string name="state_tampering_detected">Tampering detected</string>
<string name="state_update_available">Update available</string>
<string name="state_up_to_date">Up-to-date</string>
<string name="sensor_name_health_connect">Health Connect sensors</string>
</resources>

View file

@ -15,6 +15,7 @@ car-versions = "1.4.0-rc02"
changeLog = "3.5"
community-material-typeface = "7.0.96.1-kotlin"
compose-bom = "2024.06.00"
connectClient = "1.1.0-alpha07"
constraintlayout = "2.1.4"
coreKtx = "1.13.1"
core-splashscreen = "1.1.0-rc01"
@ -84,6 +85,7 @@ ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint" }
activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activity-compose" }
activity-ktx = { module = "androidx.activity:activity-ktx", version.ref = "activity-compose" }
android-beacon-library = { module = "org.altbeacon:android-beacon-library", version.ref = "androidBeaconLibrary" }
androidx-health-connect-client = { module = "androidx.health.connect:connect-client", version.ref = "connectClient" }
androidx-health-services-client = { module = "androidx.health:health-services-client", version.ref = "healthServicesClient" }
androidx-media = { module = "androidx.media:media", version.ref = "media" }
androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" }