mirror of
https://github.com/home-assistant/android
synced 2024-09-30 05:14:12 +00:00
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:
parent
50808ff98a
commit
485a842718
|
@ -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)
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
|
|
|
@ -66,6 +66,7 @@ class SensorReceiver : SensorReceiverBase() {
|
|||
DynamicColorSensorManager(),
|
||||
DevicePolicyManager(),
|
||||
GeocodeSensorManager(),
|
||||
HealthConnectSensorManager(),
|
||||
KeyguardSensorManager(),
|
||||
LastAppSensorManager(),
|
||||
LastRebootSensorManager(),
|
||||
|
|
|
@ -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()
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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" }
|
||||
|
|
Loading…
Reference in a new issue