diff --git a/README.md b/README.md index a43cbe6..4fd9eb9 100644 --- a/README.md +++ b/README.md @@ -3,90 +3,47 @@ [Tasker](https://tasker.joaoapps.com/) plugin to interface with [Health Connect](https://developer.android.com/health-connect) on Android ## Current Features -- ~~Get last X days step count~~ Retrieve all [Aggregate data](https://developer.android.com/health-and-fitness/guides/health-connect/develop/aggregate-data) for the last X days - -## Installation -- Updates are currently released only on GitHub -- Get the latest APK from the [releases](https://github.com/RafhaanShah/TaskerHealthConnect/releases) page -- [Google Play Protect](https://developers.google.com/android/play-protect) may complain about untrusted applications because the APK is currently only signed with [Android Debug Certificates](https://developer.android.com/studio/publish/app-signing) - -## Usage -- Run the app, it will check to make sure that Health Connect is installed and will prompt for required permissions -- Open Tasker, and look for 'Tasker Health Connect' inside Action -> Plugins - -### Example Output - +- Retrieve [Aggregate data](https://developer.android.com/health-and-fitness/guides/health-connect/develop/aggregate-data) for the last X days as JSON. See [HealthConnectRepository](app/src/main/java/com/rafapps/taskerhealthconnect/HealthConnectRepository.kt) and [HealthConnectDataTypes](app/src/main/java/com/rafapps/taskerhealthconnect/HealthConnectDataTypes.kt) for all data types, units, and JSON keys. ```json [ { "startTime":"2024-02-06T16:00:36.910Z", "endTime":"2024-02-07T16:00:36.910Z", - "result":{ - "Height_height_min":1.55, - "Height_height_avg":1.55, - "Height_height_max":1.55, - "Weight_weight_min":63, - "Weight_weight_avg":63.5, - "Weight_weight_max":64, - "BasalCaloriesBurned_energy_total":1523.29049265625, - "Nutrition_sugar_total":0, - "Nutrition_cholesterol_total":0, - "Nutrition_totalCarbohydrate_total":49.30000114440918, - "Nutrition_totalFat_total":1.0000000298023224, - "Nutrition_protein_total":1.600000023841858, - "Nutrition_dietaryFiber_total":5.5, - "Nutrition_iron_total":3.000000044703484E-4, - "Nutrition_vitaminA_total":2.0070000076293946E-4, - "Nutrition_vitaminC_total":0.0525, - "Nutrition_calcium_total":0.2390999984741211, - "Nutrition_calories_total":201, - "Nutrition_potassium_total":0.458, - "Nutrition_sodium_total":0.006, - "Nutrition_transFat_total":0, - "Nutrition_vitaminD_total":0, - "Nutrition_saturatedFat_total":0, - "TotalCaloriesBurned_energy_total":1523.29049265625, - "HeartRateSeries_bpm_min":68, - "HeartRate_bpm_min":68, - "HeartRateSeries_bpm_avg":79, - "HeartRate_bpm_avg":79, - "HeartRateSeries_bpm_max":92, - "HeartRate_bpm_max":92, - "Steps_count_total":5660, - "HeartRateSeries_count":48, - "HeartRate_count":48, - "SleepSession_duration":17040000 - } + "FloorsClimbedRecord_FLOORS_CLIMBED_TOTAL": 5.75, + "ActiveCaloriesBurnedRecord_ACTIVE_CALORIES_TOTAL": 1532.1092 }, { "startTime":"2024-02-05T16:00:36.910Z", "endTime":"2024-02-06T16:00:36.910Z", - "result":{ - "BasalCaloriesBurned_energy_total":1564.5, - "TotalCaloriesBurned_energy_total":1564.5, - "Steps_count_total":2 - } - }, - { - "startTime":"2024-02-07T16:00:36.910Z", - "endTime":"2024-02-08T12:01:36.910Z", - "result":{ - "BasalCaloriesBurned_energy_total":1266.8881944444443, - "TotalCaloriesBurned_energy_total":1266.8881944444443, - "Steps_count_total":1423 - } + "HeartRateRecord_BPM_AVG": 69, + "StepsRecord_COUNT_TOTAL": 19822 } ] ``` +## Installation +- Updates are currently released only on GitHub +- Get the latest APK from the [releases](https://github.com/RafhaanShah/TaskerHealthConnect/releases) page +- [Google Play Protect](https://developers.google.com/android/play-protect) may complain about untrusted applications because the APK is currently only signed with [Android Debug Certificates](https://developer.android.com/studio/publish/app-signing) +- Check release / update notes as the plugin is not considered stable, and breaking API changes are to be expected + +## Usage +- Run the app, it will check to make sure that Health Connect is installed and will prompt for required permissions +- Open Tasker, and look for 'Tasker Health Connect' inside Action -> Plugins + ## Building - Clone the repository: `git clone https://github.com/RafhaanShah/TaskerHealthConnect` - Build with gradle: `./gradlew assembleDebug` +- Or just open in Android Studio and click run + +## Testing +- Download the [Health Connect Toolbox](https://developer.android.com/health-and-fitness/guides/health-connect/test/health-connect-toolbox) to read and write test data +- Activities will have additional debug buttons to log output info on debug builds -# Contributing / Feature Requests +## Contributing / Feature Requests - Contributions via pull requests are welcome! -- Tasker plugin documentation can be found [here](https://developer.android.com/guide/health-and-fitness/health-connect/get-started) -- Health Connect documentation can be found [here](https://tasker.joaoapps.com/pluginslibrary.html) +- Health Connect documentation can be found [here](https://developer.android.com/guide/health-and-fitness/health-connect/get-started) +- Tasker plugin documentation can be found [here](https://tasker.joaoapps.com/pluginslibrary.html) - For feature requests please make a GitHub issue [here](https://github.com/RafhaanShah/TaskerHealthConnect/issues?q=is%3Aissue+is%3Aopen+sort%3Aupdated-desc) with details ## License diff --git a/app/build.gradle b/app/build.gradle index e18926d..4fc17be 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -42,6 +42,7 @@ android { } buildFeatures { + buildConfig true viewBinding true } } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 8b1a602..9134133 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,49 +1,58 @@ + xmlns:tools="http://schemas.android.com/tools"> + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + tools:targetApi="34"> - + android:exported="true"> + + + + + + - - + + + android:label="@string/aggregated_health_data"> diff --git a/app/src/main/java/com/rafapps/taskerhealthconnect/HealthConnectDataTypes.kt b/app/src/main/java/com/rafapps/taskerhealthconnect/HealthConnectDataTypes.kt new file mode 100644 index 0000000..6a51659 --- /dev/null +++ b/app/src/main/java/com/rafapps/taskerhealthconnect/HealthConnectDataTypes.kt @@ -0,0 +1,183 @@ +package com.rafapps.taskerhealthconnect + +import androidx.health.connect.client.records.ActiveCaloriesBurnedRecord +import androidx.health.connect.client.records.BasalBodyTemperatureRecord +import androidx.health.connect.client.records.BasalMetabolicRateRecord +import androidx.health.connect.client.records.BloodGlucoseRecord +import androidx.health.connect.client.records.BloodPressureRecord +import androidx.health.connect.client.records.BodyFatRecord +import androidx.health.connect.client.records.BodyTemperatureRecord +import androidx.health.connect.client.records.BodyWaterMassRecord +import androidx.health.connect.client.records.BoneMassRecord +import androidx.health.connect.client.records.CervicalMucusRecord +import androidx.health.connect.client.records.CyclingPedalingCadenceRecord +import androidx.health.connect.client.records.DistanceRecord +import androidx.health.connect.client.records.ElevationGainedRecord +import androidx.health.connect.client.records.ExerciseSessionRecord +import androidx.health.connect.client.records.FloorsClimbedRecord +import androidx.health.connect.client.records.HeartRateRecord +import androidx.health.connect.client.records.HeartRateVariabilityRmssdRecord +import androidx.health.connect.client.records.HeightRecord +import androidx.health.connect.client.records.HydrationRecord +import androidx.health.connect.client.records.LeanBodyMassRecord +import androidx.health.connect.client.records.MenstruationFlowRecord +import androidx.health.connect.client.records.NutritionRecord +import androidx.health.connect.client.records.OvulationTestRecord +import androidx.health.connect.client.records.OxygenSaturationRecord +import androidx.health.connect.client.records.PowerRecord +import androidx.health.connect.client.records.RespiratoryRateRecord +import androidx.health.connect.client.records.RestingHeartRateRecord +import androidx.health.connect.client.records.SexualActivityRecord +import androidx.health.connect.client.records.SleepSessionRecord +import androidx.health.connect.client.records.SpeedRecord +import androidx.health.connect.client.records.StepsCadenceRecord +import androidx.health.connect.client.records.StepsRecord +import androidx.health.connect.client.records.TotalCaloriesBurnedRecord +import androidx.health.connect.client.records.Vo2MaxRecord +import androidx.health.connect.client.records.WeightRecord +import androidx.health.connect.client.records.WheelchairPushesRecord + +val singleRecordTypes = setOf( + BasalBodyTemperatureRecord::class, + BloodGlucoseRecord::class, + BloodPressureRecord::class, + BodyFatRecord::class, + BodyTemperatureRecord::class, + BodyWaterMassRecord::class, + BoneMassRecord::class, + CervicalMucusRecord::class, + CyclingPedalingCadenceRecord::class, + HeartRateVariabilityRmssdRecord::class, + LeanBodyMassRecord::class, + MenstruationFlowRecord::class, + OvulationTestRecord::class, + OxygenSaturationRecord::class, + RespiratoryRateRecord::class, + SexualActivityRecord::class, + SpeedRecord::class, + StepsCadenceRecord::class, + Vo2MaxRecord::class, +) + +val aggregateRecordTypes = setOf( + ActiveCaloriesBurnedRecord::class, + BasalMetabolicRateRecord::class, + DistanceRecord::class, + ElevationGainedRecord::class, + ExerciseSessionRecord::class, + FloorsClimbedRecord::class, + HeartRateRecord::class, + HeightRecord::class, + HydrationRecord::class, + NutritionRecord::class, + PowerRecord::class, + RestingHeartRateRecord::class, + SleepSessionRecord::class, + StepsRecord::class, + TotalCaloriesBurnedRecord::class, + WeightRecord::class, + WheelchairPushesRecord::class, +) + +// from androidx/health/connect/client/impl/platform/records/AggregationMappings + val doubleAggregations = mapOf( + "FloorsClimbedRecord_FLOORS_CLIMBED_TOTAL" to FloorsClimbedRecord.FLOORS_CLIMBED_TOTAL, +) + + val durationAggregations = setOf( + "ExerciseSessionRecord_EXERCISE_DURATION_TOTAL" to ExerciseSessionRecord.EXERCISE_DURATION_TOTAL, + "SleepSessionRecord_SLEEP_DURATION_TOTAL" to SleepSessionRecord.SLEEP_DURATION_TOTAL, +) + + val energyAggregations = setOf( + "ActiveCaloriesBurnedRecord_ACTIVE_CALORIES_TOTAL" to ActiveCaloriesBurnedRecord.ACTIVE_CALORIES_TOTAL, + "BasalMetabolicRateRecord_BASAL_CALORIES_TOTAL" to BasalMetabolicRateRecord.BASAL_CALORIES_TOTAL, + "NutritionRecord_ENERGY_TOTAL" to NutritionRecord.ENERGY_TOTAL, + "NutritionRecord_ENERGY_FROM_FAT_TOTAL" to NutritionRecord.ENERGY_FROM_FAT_TOTAL, + "TotalCaloriesBurnedRecord_ENERGY_TOTAL" to TotalCaloriesBurnedRecord.ENERGY_TOTAL, +) + + val lengthAggregations = mapOf( + "DistanceRecord_DISTANCE_TOTAL" to DistanceRecord.DISTANCE_TOTAL, + "ElevationGainedRecord_ELEVATION_GAINED_TOTAL" to ElevationGainedRecord.ELEVATION_GAINED_TOTAL, + "HeightRecord_HEIGHT_AVG" to HeightRecord.HEIGHT_AVG, + "HeightRecord_HEIGHT_MIN" to HeightRecord.HEIGHT_MIN, + "HeightRecord_HEIGHT_MAX" to HeightRecord.HEIGHT_MAX, +) + + val longAggregations = mapOf( + "HeartRateRecord_BPM_AVG" to HeartRateRecord.BPM_AVG, + "HeartRateRecord_BPM_MIN" to HeartRateRecord.BPM_MIN, + "HeartRateRecord_BPM_MAX" to HeartRateRecord.BPM_MAX, + "HeartRateRecord_MEASUREMENTS_COUNT" to HeartRateRecord.MEASUREMENTS_COUNT, + "RestingHeartRateRecord_BPM_AVG" to RestingHeartRateRecord.BPM_AVG, + "RestingHeartRateRecord_BPM_MIN" to RestingHeartRateRecord.BPM_MIN, + "RestingHeartRateRecord_BPM_MAX" to RestingHeartRateRecord.BPM_MAX, + "StepsRecord_COUNT_TOTAL" to StepsRecord.COUNT_TOTAL, + "WheelchairPushesRecord_COUNT_TOTAL" to WheelchairPushesRecord.COUNT_TOTAL +) + + val gramsAggregations = mapOf( + "NutritionRecord_ENERGY_TOTAL" to NutritionRecord.ENERGY_TOTAL, + "NutritionRecord_ENERGY_FROM_FAT_TOTAL" to NutritionRecord.ENERGY_FROM_FAT_TOTAL, + "NutritionRecord_BIOTIN_TOTAL" to NutritionRecord.BIOTIN_TOTAL, + "NutritionRecord_CAFFEINE_TOTAL" to NutritionRecord.CAFFEINE_TOTAL, + "NutritionRecord_CALCIUM_TOTAL" to NutritionRecord.CALCIUM_TOTAL, + "NutritionRecord_CHLORIDE_TOTAL" to NutritionRecord.CHLORIDE_TOTAL, + "NutritionRecord_CHOLESTEROL_TOTAL" to NutritionRecord.CHOLESTEROL_TOTAL, + "NutritionRecord_CHROMIUM_TOTAL" to NutritionRecord.CHROMIUM_TOTAL, + "NutritionRecord_COPPER_TOTAL" to NutritionRecord.COPPER_TOTAL, + "NutritionRecord_DIETARY_FIBER_TOTAL" to NutritionRecord.DIETARY_FIBER_TOTAL, + "NutritionRecord_FOLATE_TOTAL" to NutritionRecord.FOLATE_TOTAL, + "NutritionRecord_FOLIC_ACID_TOTAL" to NutritionRecord.FOLIC_ACID_TOTAL, + "NutritionRecord_IODINE_TOTAL" to NutritionRecord.IODINE_TOTAL, + "NutritionRecord_IRON_TOTAL" to NutritionRecord.IRON_TOTAL, + "NutritionRecord_MAGNESIUM_TOTAL" to NutritionRecord.MAGNESIUM_TOTAL, + "NutritionRecord_MANGANESE_TOTAL" to NutritionRecord.MANGANESE_TOTAL, + "NutritionRecord_MOLYBDENUM_TOTAL" to NutritionRecord.MOLYBDENUM_TOTAL, + "NutritionRecord_MONOUNSATURATED_FAT_TOTAL" to NutritionRecord.MONOUNSATURATED_FAT_TOTAL, + "NutritionRecord_NIACIN_TOTAL" to NutritionRecord.NIACIN_TOTAL, + "NutritionRecord_PANTOTHENIC_ACID_TOTAL" to NutritionRecord.PANTOTHENIC_ACID_TOTAL, + "NutritionRecord_PHOSPHORUS_TOTAL" to NutritionRecord.PHOSPHORUS_TOTAL, + "NutritionRecord_POLYUNSATURATED_FAT_TOTAL" to NutritionRecord.POLYUNSATURATED_FAT_TOTAL, + "NutritionRecord_POTASSIUM_TOTAL" to NutritionRecord.POTASSIUM_TOTAL, + "NutritionRecord_PROTEIN_TOTAL" to NutritionRecord.PROTEIN_TOTAL, + "NutritionRecord_RIBOFLAVIN_TOTAL" to NutritionRecord.RIBOFLAVIN_TOTAL, + "NutritionRecord_SATURATED_FAT_TOTAL" to NutritionRecord.SATURATED_FAT_TOTAL, + "NutritionRecord_SELENIUM_TOTAL" to NutritionRecord.SELENIUM_TOTAL, + "NutritionRecord_SODIUM_TOTAL" to NutritionRecord.SODIUM_TOTAL, + "NutritionRecord_SUGAR_TOTAL" to NutritionRecord.SUGAR_TOTAL, + "NutritionRecord_THIAMIN_TOTAL" to NutritionRecord.THIAMIN_TOTAL, + "NutritionRecord_TOTAL_CARBOHYDRATE_TOTAL" to NutritionRecord.TOTAL_CARBOHYDRATE_TOTAL, + "NutritionRecord_TOTAL_FAT_TOTAL" to NutritionRecord.TOTAL_FAT_TOTAL, + "NutritionRecord_UNSATURATED_FAT_TOTAL" to NutritionRecord.UNSATURATED_FAT_TOTAL, + "NutritionRecord_VITAMIN_A_TOTAL" to NutritionRecord.VITAMIN_A_TOTAL, + "NutritionRecord_VITAMIN_B12_TOTAL" to NutritionRecord.VITAMIN_B12_TOTAL, + "NutritionRecord_VITAMIN_B6_TOTAL" to NutritionRecord.VITAMIN_B6_TOTAL, + "NutritionRecord_VITAMIN_C_TOTAL" to NutritionRecord.VITAMIN_C_TOTAL, + "NutritionRecord_VITAMIN_D_TOTAL" to NutritionRecord.VITAMIN_D_TOTAL, + "NutritionRecord_VITAMIN_E_TOTAL" to NutritionRecord.VITAMIN_E_TOTAL, + "NutritionRecord_VITAMIN_K_TOTAL" to NutritionRecord.VITAMIN_K_TOTAL, + "NutritionRecord_ZINC_TOTAL" to NutritionRecord.ZINC_TOTAL +) + + val kilogramsAggregations = mapOf( + "WeightRecord_WEIGHT_AVG" to WeightRecord.WEIGHT_AVG, + "WeightRecord_WEIGHT_MIN" to WeightRecord.WEIGHT_MIN, + "WeightRecord_WEIGHT_MAX" to WeightRecord.WEIGHT_MAX, +) + + val powerAggregations = mapOf( + "PowerRecord_POWER_AVG" to PowerRecord.POWER_AVG, + "PowerRecord_POWER_MAX" to PowerRecord.POWER_MAX, + "PowerRecord_POWER_MIN" to PowerRecord.POWER_MIN, +) + + val volumeAggregations = mapOf( + "HydrationRecord_VOLUME_TOTAL" to HydrationRecord.VOLUME_TOTAL, +) + + val aggregateMetricTypes = + doubleAggregations + durationAggregations + energyAggregations + lengthAggregations + + longAggregations + gramsAggregations + kilogramsAggregations + powerAggregations + + volumeAggregations diff --git a/app/src/main/java/com/rafapps/taskerhealthconnect/HealthConnectRepository.kt b/app/src/main/java/com/rafapps/taskerhealthconnect/HealthConnectRepository.kt index 35b90cb..bfa573b 100644 --- a/app/src/main/java/com/rafapps/taskerhealthconnect/HealthConnectRepository.kt +++ b/app/src/main/java/com/rafapps/taskerhealthconnect/HealthConnectRepository.kt @@ -5,100 +5,35 @@ import android.content.Intent import android.net.Uri import android.util.Log import androidx.health.connect.client.HealthConnectClient -import androidx.health.connect.client.aggregate.AggregateMetric -import androidx.health.connect.client.aggregate.AggregationResult import androidx.health.connect.client.permission.HealthPermission -import androidx.health.connect.client.records.* import androidx.health.connect.client.request.AggregateGroupByDurationRequest import androidx.health.connect.client.time.TimeRangeFilter +import androidx.health.connect.client.units.Energy +import androidx.health.connect.client.units.Length +import androidx.health.connect.client.units.Mass +import androidx.health.connect.client.units.Power +import androidx.health.connect.client.units.Volume import org.json.JSONArray import org.json.JSONObject import java.lang.Exception import java.time.Duration import java.time.Instant -import kotlin.reflect.KClass -import kotlin.reflect.full.companionObject -import kotlin.reflect.full.declaredMemberProperties -import kotlin.reflect.full.memberProperties -import kotlin.reflect.jvm.isAccessible -import kotlin.reflect.typeOf class HealthConnectRepository(private val context: Context) { - companion object { - val TAG = "HealthConnectRepository" - } + private val TAG = "HealthConnectRepository" private val playStoreUri = "https://play.google.com/store/apps/details?id=com.google.android.apps.healthdata" private val client by lazy { HealthConnectClient.getOrCreate(context) } - val recordTypes = setOf>( - ActiveCaloriesBurnedRecord::class, - BasalBodyTemperatureRecord::class, - BasalMetabolicRateRecord::class, - BloodGlucoseRecord::class, - BloodPressureRecord::class, - BodyFatRecord::class, - BodyTemperatureRecord::class, - BodyWaterMassRecord::class, - BoneMassRecord::class, - CervicalMucusRecord::class, - CyclingPedalingCadenceRecord::class, - DistanceRecord::class, - ElevationGainedRecord::class, - ExerciseSessionRecord::class, - FloorsClimbedRecord::class, - HeartRateRecord::class, - HeartRateVariabilityRmssdRecord::class, - HeightRecord::class, - HydrationRecord::class, - LeanBodyMassRecord::class, - MenstruationFlowRecord::class, - NutritionRecord::class, - OvulationTestRecord::class, - OxygenSaturationRecord::class, - PowerRecord::class, - RespiratoryRateRecord::class, - RestingHeartRateRecord::class, - SexualActivityRecord::class, - SleepSessionRecord::class, - SpeedRecord::class, - StepsCadenceRecord::class, - StepsRecord::class, - TotalCaloriesBurnedRecord::class, - Vo2MaxRecord::class, - WeightRecord::class, - WheelchairPushesRecord::class, - ) - - val permissions by lazy { recordTypes.map { HealthPermission.createReadPermission(it) }.toSet() } - - fun getAllAggregateMetrics(): Set> { - val aggregateMetrics = mutableSetOf>() - val targetType = typeOf>() - - recordTypes.forEach { recordType -> - try { - val companionObj = recordType.companionObject?.objectInstance ?: return@forEach - val properties = companionObj::class.declaredMemberProperties.filter { - it.returnType.classifier == targetType.classifier - } - - properties.forEach { property -> - val value = property.getter.call(companionObj) as AggregateMetric<*> - aggregateMetrics.add(value) - } - } - catch (e: Exception) { - Log.e(TAG, "getAllAggregateMetrics: ", e) - } - } - return aggregateMetrics.toSet() + val permissions by lazy { + aggregateRecordTypes.map { HealthPermission.getReadPermission(it) }.toSet() } fun installHealthConnect() { + Log.d(TAG, "installHealthConnect") val intent = Intent(Intent.ACTION_VIEW).apply { data = Uri.parse(playStoreUri) setPackage("com.android.vending") @@ -107,53 +42,60 @@ class HealthConnectRepository(private val context: Context) { runCatching { context.startActivity(intent) } } - fun isAvailable(): Boolean = HealthConnectClient.isAvailable(context) + fun isAvailable(): Boolean = + HealthConnectClient.getSdkStatus(context) == HealthConnectClient.SDK_AVAILABLE suspend fun hasPermissions(): Boolean { - val granted = client.permissionController.getGrantedPermissions(permissions) + val granted = client.permissionController.getGrantedPermissions() return granted.containsAll(permissions) } - suspend fun getData( - startTime: Instant, - endTime: Instant - ): String { - val result: JSONArray = JSONArray() - val metrics = getAllAggregateMetrics() - - try { - val response = client.aggregateGroupByDuration( - AggregateGroupByDurationRequest( - metrics = metrics, - timeRangeFilter = TimeRangeFilter.between(startTime, endTime), - timeRangeSlicer = Duration.ofDays(1) - ) + suspend fun getAggregateData( + startTime: Instant, endTime: Instant + ): JSONArray { + Log.d(TAG, "getData: $startTime -> $endTime") + var dataPointSize = 0 + val result = JSONArray() + val response = client.aggregateGroupByDuration( + AggregateGroupByDurationRequest( + metrics = aggregateMetricTypes.values.toSet(), + timeRangeFilter = TimeRangeFilter.between(startTime, endTime), + timeRangeSlicer = Duration.ofDays(1) ) - response.forEach { - val data = mutableMapOf() - val properties = AggregationResult::class.memberProperties - properties.forEach { p -> - run { - p.isAccessible = true; - if (p.name == "doubleValues" || p.name == "longValues") { - val values = p.call(it.result) - - data.putAll(values as Map) + ) + + // loop through the "buckets" of data where each bucket is 1 day + response.forEach { aggregationResult -> + val data = mutableMapOf() + data["startTime"] = aggregationResult.startTime + data["endTime"] = aggregationResult.endTime + + // check for each data type in each bucket + aggregateMetricTypes.forEach { metricType -> + aggregationResult.result[metricType.value]?.let { dataPoint -> + dataPointSize++ + when (dataPoint) { + is Double -> data[metricType.key] = dataPoint + is Duration -> data[metricType.key] = dataPoint.toMinutes() + is Energy -> data[metricType.key] = dataPoint.inCalories + is Length -> data[metricType.key] = dataPoint.inMeters + is Long -> data[metricType.key] = dataPoint + is Mass -> data[metricType.key] = dataPoint.inKilograms + is Power -> data[metricType.key] = dataPoint.inWatts + is Volume -> data[metricType.key] = dataPoint.inLiters + else -> { + Log.e(TAG, "Unexpected data type: $dataPoint") + data[metricType.key] = dataPoint.toString() } } } - result.put(JSONObject(mapOf( - "startTime" to it.startTime, - "endTime" to it.endTime, - "result" to data - ))) } - return result.toString() - } - catch (e: Exception) { - Log.e(TAG, "getData: ", e) - return "[]" + + // put the data for that day as an object in the output array + result.put(JSONObject(data.toMap())) } + Log.d(TAG, "aggregationResults: ${response.size}, dataPointSize: $dataPointSize") + return result } } diff --git a/app/src/main/java/com/rafapps/taskerhealthconnect/MainActivity.kt b/app/src/main/java/com/rafapps/taskerhealthconnect/MainActivity.kt index 7657339..0c09c25 100644 --- a/app/src/main/java/com/rafapps/taskerhealthconnect/MainActivity.kt +++ b/app/src/main/java/com/rafapps/taskerhealthconnect/MainActivity.kt @@ -1,6 +1,7 @@ package com.rafapps.taskerhealthconnect import android.os.Bundle +import android.util.Log import androidx.appcompat.app.AppCompatActivity import androidx.core.view.WindowCompat import androidx.core.view.isVisible @@ -11,10 +12,9 @@ import kotlinx.coroutines.launch class MainActivity : AppCompatActivity() { + private val TAG = "MainActivity" private lateinit var binding: ActivityMainBinding - private val repository by lazy { HealthConnectRepository(this) } - private val permissionsLauncher = registerForActivityResult( PermissionController.createRequestPermissionResultContract() @@ -27,6 +27,7 @@ class MainActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { + Log.d(TAG, "onCreate") WindowCompat.setDecorFitsSystemWindows(window, false) super.onCreate(savedInstanceState) @@ -36,10 +37,10 @@ class MainActivity : AppCompatActivity() { override fun onResume() { super.onResume() - checkHealthConnectAndPermissions() + checkAvailabilityAndPermissions() } - private fun checkHealthConnectAndPermissions() { + private fun checkAvailabilityAndPermissions() { if (!repository.isAvailable()) onHealthConnectUnavailable() else @@ -54,6 +55,7 @@ class MainActivity : AppCompatActivity() { private fun requestPermission() = permissionsLauncher.launch(repository.permissions) private fun onHealthConnectUnavailable() { + Log.d(TAG, "onHealthConnectUnavailable") with(binding) { textView.text = getString(R.string.health_connect_unavailable) button.text = getString(R.string.install) @@ -63,13 +65,15 @@ class MainActivity : AppCompatActivity() { } private fun onPermissionGranted() { + Log.d(TAG, "onPermissionGranted") with(binding) { - textView.text = getString(R.string.permissions_granted) + textView.text = getString(R.string.app_ready) button.isVisible = false } } private fun onPermissionDenied() { + Log.d(TAG, "onPermissionDenied") with(binding) { textView.text = getString(R.string.permissions_not_granted) button.text = getString(R.string.grant_permissions) diff --git a/app/src/main/java/com/rafapps/taskerhealthconnect/aggregated/AggregatedHealthDataActionHelper.kt b/app/src/main/java/com/rafapps/taskerhealthconnect/aggregated/AggregatedHealthDataActionHelper.kt new file mode 100644 index 0000000..f96bb9e --- /dev/null +++ b/app/src/main/java/com/rafapps/taskerhealthconnect/aggregated/AggregatedHealthDataActionHelper.kt @@ -0,0 +1,11 @@ +package com.rafapps.taskerhealthconnect.aggregated + +import com.joaomgcd.taskerpluginlibrary.config.TaskerPluginConfig +import com.joaomgcd.taskerpluginlibrary.config.TaskerPluginConfigHelper + +class AggregatedHealthDataActionHelper(config: TaskerPluginConfig) : + TaskerPluginConfigHelper(config) { + override val inputClass = AggregatedHealthDataInput::class.java + override val outputClass = AggregatedHealthDataOutput::class.java + override val runnerClass = AggregatedHealthDataActionRunner::class.java +} diff --git a/app/src/main/java/com/rafapps/taskerhealthconnect/aggregated/AggregatedHealthDataActionRunner.kt b/app/src/main/java/com/rafapps/taskerhealthconnect/aggregated/AggregatedHealthDataActionRunner.kt new file mode 100644 index 0000000..bf6676a --- /dev/null +++ b/app/src/main/java/com/rafapps/taskerhealthconnect/aggregated/AggregatedHealthDataActionRunner.kt @@ -0,0 +1,59 @@ +package com.rafapps.taskerhealthconnect.aggregated + +import android.content.Context +import android.util.Log +import com.joaomgcd.taskerpluginlibrary.action.TaskerPluginRunnerAction +import com.joaomgcd.taskerpluginlibrary.input.TaskerInput +import com.joaomgcd.taskerpluginlibrary.runner.TaskerPluginResult +import com.joaomgcd.taskerpluginlibrary.runner.TaskerPluginResultErrorWithOutput +import com.joaomgcd.taskerpluginlibrary.runner.TaskerPluginResultSucess +import com.rafapps.taskerhealthconnect.HealthConnectRepository +import com.rafapps.taskerhealthconnect.R +import kotlinx.coroutines.runBlocking +import java.time.Instant +import java.time.ZonedDateTime + +class AggregatedHealthDataActionRunner : + TaskerPluginRunnerAction() { + + private val TAG = "AggregatedHealthDataActionRunner" + + override val notificationProperties + get() = NotificationProperties(iconResId = R.drawable.ic_launcher_foreground) + + override fun run( + context: Context, + input: TaskerInput + ): TaskerPluginResult { + Log.d(TAG, "run: $input") + val repository = HealthConnectRepository(context) + val offsetTime = daysToOffsetTime(input.regular.days) + + if (!repository.isAvailable() || runBlocking { !repository.hasPermissions() }) { + val errMessage = context.getString(R.string.health_connect_unavailable_or_permissions) + Log.d(TAG, errMessage) + return TaskerPluginResultErrorWithOutput(Throwable(errMessage)) + } + + return try { + val data = runBlocking { + repository.getAggregateData(startTime = offsetTime, endTime = Instant.now()) + } + TaskerPluginResultSucess(AggregatedHealthDataOutput(aggregatedHealthData = data.toString())) + } catch (e: Exception) { + Log.e(TAG, "run error:", e) + TaskerPluginResultErrorWithOutput(e) + } + } + + companion object { + fun daysToOffsetTime(daysOffset: Long): Instant { + val zonedDateTime = ZonedDateTime.now() + return zonedDateTime.minusDays(daysOffset) + .minusHours(zonedDateTime.hour.toLong()) + .minusMinutes(zonedDateTime.minute.toLong()) + .minusSeconds(zonedDateTime.second.toLong()) + .toInstant() + } + } +} diff --git a/app/src/main/java/com/rafapps/taskerhealthconnect/tasker/GetHealthDataActivity.kt b/app/src/main/java/com/rafapps/taskerhealthconnect/aggregated/AggregatedHealthDataActivity.kt similarity index 57% rename from app/src/main/java/com/rafapps/taskerhealthconnect/tasker/GetHealthDataActivity.kt rename to app/src/main/java/com/rafapps/taskerhealthconnect/aggregated/AggregatedHealthDataActivity.kt index 5ffccf9..e2408f9 100644 --- a/app/src/main/java/com/rafapps/taskerhealthconnect/tasker/GetHealthDataActivity.kt +++ b/app/src/main/java/com/rafapps/taskerhealthconnect/aggregated/AggregatedHealthDataActivity.kt @@ -1,24 +1,32 @@ -package com.rafapps.taskerhealthconnect.tasker +package com.rafapps.taskerhealthconnect.aggregated import android.content.Context +import android.os.Build import android.os.Bundle +import android.util.Log import android.view.inputmethod.InputMethodManager import androidx.appcompat.app.AppCompatActivity import androidx.core.view.WindowCompat +import androidx.core.view.isVisible import androidx.health.connect.client.PermissionController import androidx.lifecycle.lifecycleScope +import com.google.android.material.internal.ViewUtils.hideKeyboard import com.joaomgcd.taskerpluginlibrary.config.TaskerPluginConfig import com.joaomgcd.taskerpluginlibrary.input.TaskerInput +import com.rafapps.taskerhealthconnect.BuildConfig import com.rafapps.taskerhealthconnect.HealthConnectRepository import com.rafapps.taskerhealthconnect.R -import com.rafapps.taskerhealthconnect.databinding.ActivityGetDataBinding +import com.rafapps.taskerhealthconnect.databinding.ActivityAggregatedHealthDataBinding import kotlinx.coroutines.launch +import java.time.Instant -class GetHealthDataActivity : AppCompatActivity(), TaskerPluginConfig { +class AggregatedHealthDataActivity : AppCompatActivity(), + TaskerPluginConfig { - private lateinit var binding: ActivityGetDataBinding + private val TAG = "AggregatedHealthDataActivity" + private lateinit var binding: ActivityAggregatedHealthDataBinding private val repository by lazy { HealthConnectRepository(this) } - private val taskerHelper by lazy { GetHealthDataActionHelper(this) } + private val taskerHelper by lazy { AggregatedHealthDataActionHelper(this) } private val permissionsLauncher = registerForActivityResult( @@ -31,23 +39,23 @@ class GetHealthDataActivity : AppCompatActivity(), TaskerPluginConfig + override val inputForTasker: TaskerInput get() = TaskerInput( - GetHealthDataInput(days = runCatching { - binding.daysText.editText?.text.toString().toLong() - }.getOrDefault(0)) + AggregatedHealthDataInput(days = getInputDays()) ) - override fun assignFromInput(input: TaskerInput) { + override fun assignFromInput(input: TaskerInput) { binding.daysText.editText?.setText(input.regular.days.toString()) } override fun onCreate(savedInstanceState: Bundle?) { + Log.d(TAG, "onCreate") WindowCompat.setDecorFitsSystemWindows(window, false) super.onCreate(savedInstanceState) - binding = ActivityGetDataBinding.inflate(layoutInflater) + binding = ActivityAggregatedHealthDataBinding.inflate(layoutInflater) setContentView(binding.root) + setDebugButton() taskerHelper.onCreate() } @@ -77,6 +85,7 @@ class GetHealthDataActivity : AppCompatActivity(), TaskerPluginConfig + Log.e(TAG, "Repository error:", err) + } + } + } + } } diff --git a/app/src/main/java/com/rafapps/taskerhealthconnect/tasker/GetHealthDataInput.kt b/app/src/main/java/com/rafapps/taskerhealthconnect/aggregated/AggregatedHealthDataInput.kt similarity index 64% rename from app/src/main/java/com/rafapps/taskerhealthconnect/tasker/GetHealthDataInput.kt rename to app/src/main/java/com/rafapps/taskerhealthconnect/aggregated/AggregatedHealthDataInput.kt index dfb699c..0573b88 100644 --- a/app/src/main/java/com/rafapps/taskerhealthconnect/tasker/GetHealthDataInput.kt +++ b/app/src/main/java/com/rafapps/taskerhealthconnect/aggregated/AggregatedHealthDataInput.kt @@ -1,15 +1,15 @@ -package com.rafapps.taskerhealthconnect.tasker +package com.rafapps.taskerhealthconnect.aggregated import android.annotation.SuppressLint import com.joaomgcd.taskerpluginlibrary.input.TaskerInputField import com.joaomgcd.taskerpluginlibrary.input.TaskerInputRoot import com.rafapps.taskerhealthconnect.R -@SuppressLint("NonConstantResourceId") +@SuppressLint("NonConstantResourceId") // TODO: check with nonFinalResIds @TaskerInputRoot -class GetHealthDataInput @JvmOverloads constructor( +class AggregatedHealthDataInput @JvmOverloads constructor( @field:TaskerInputField( - key = VARIABLE_NAME_DAYS, + key = "days", labelResId = R.string.days, descriptionResId = R.string.days_description ) var days: Long = 0L diff --git a/app/src/main/java/com/rafapps/taskerhealthconnect/aggregated/AggregatedHealthDataOutput.kt b/app/src/main/java/com/rafapps/taskerhealthconnect/aggregated/AggregatedHealthDataOutput.kt new file mode 100644 index 0000000..851c92c --- /dev/null +++ b/app/src/main/java/com/rafapps/taskerhealthconnect/aggregated/AggregatedHealthDataOutput.kt @@ -0,0 +1,16 @@ +package com.rafapps.taskerhealthconnect.aggregated + +import android.annotation.SuppressLint +import com.joaomgcd.taskerpluginlibrary.output.TaskerOutputObject +import com.joaomgcd.taskerpluginlibrary.output.TaskerOutputVariable +import com.rafapps.taskerhealthconnect.R + +@SuppressLint("NonConstantResourceId") // TODO: check with nonFinalResIds +@TaskerOutputObject +class AggregatedHealthDataOutput( + @get:TaskerOutputVariable( + name = "aggregatedHealthData", + labelResId = R.string.aggregated_health_data, + htmlLabelResId = R.string.aggregated_health_data_description + ) val aggregatedHealthData: String = "[]" +) diff --git a/app/src/main/java/com/rafapps/taskerhealthconnect/tasker/GetHealthDataActionHelper.kt b/app/src/main/java/com/rafapps/taskerhealthconnect/tasker/GetHealthDataActionHelper.kt deleted file mode 100644 index b31d573..0000000 --- a/app/src/main/java/com/rafapps/taskerhealthconnect/tasker/GetHealthDataActionHelper.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.rafapps.taskerhealthconnect.tasker - -import com.joaomgcd.taskerpluginlibrary.config.TaskerPluginConfig -import com.joaomgcd.taskerpluginlibrary.config.TaskerPluginConfigHelper - -class GetHealthDataActionHelper(config: TaskerPluginConfig) : - TaskerPluginConfigHelper(config) { - override val inputClass = GetHealthDataInput::class.java - override val outputClass = GetHealthDataOutput::class.java - override val runnerClass = GetHealthDataActionRunner::class.java -} diff --git a/app/src/main/java/com/rafapps/taskerhealthconnect/tasker/GetHealthDataActionRunner.kt b/app/src/main/java/com/rafapps/taskerhealthconnect/tasker/GetHealthDataActionRunner.kt deleted file mode 100644 index 81109b4..0000000 --- a/app/src/main/java/com/rafapps/taskerhealthconnect/tasker/GetHealthDataActionRunner.kt +++ /dev/null @@ -1,34 +0,0 @@ -package com.rafapps.taskerhealthconnect.tasker - -import android.content.Context -import com.joaomgcd.taskerpluginlibrary.action.TaskerPluginRunnerAction -import com.joaomgcd.taskerpluginlibrary.input.TaskerInput -import com.joaomgcd.taskerpluginlibrary.runner.TaskerPluginResult -import com.joaomgcd.taskerpluginlibrary.runner.TaskerPluginResultSucess -import com.rafapps.taskerhealthconnect.HealthConnectRepository -import com.rafapps.taskerhealthconnect.R -import kotlinx.coroutines.runBlocking -import java.time.Instant -import java.time.ZonedDateTime - -class GetHealthDataActionRunner : TaskerPluginRunnerAction() { - override val notificationProperties - get() = NotificationProperties(iconResId = R.drawable.ic_launcher_foreground) - - override fun run( - context: Context, - input: TaskerInput - ): TaskerPluginResult { - val repository = HealthConnectRepository(context) - val daysOffset = input.regular.days - val now = Instant.now() - val zonedDateTime = ZonedDateTime.now() - val offsetTime = zonedDateTime.minusDays(daysOffset) - .minusHours(zonedDateTime.hour.toLong()) - .minusMinutes(zonedDateTime.minute.toLong()) - .toInstant() - - val data = runBlocking { repository.getData(startTime = offsetTime, endTime = now) } - return TaskerPluginResultSucess(GetHealthDataOutput(data)) - } -} diff --git a/app/src/main/java/com/rafapps/taskerhealthconnect/tasker/GetHealthDataConstants.kt b/app/src/main/java/com/rafapps/taskerhealthconnect/tasker/GetHealthDataConstants.kt deleted file mode 100644 index c39b33a..0000000 --- a/app/src/main/java/com/rafapps/taskerhealthconnect/tasker/GetHealthDataConstants.kt +++ /dev/null @@ -1,4 +0,0 @@ -package com.rafapps.taskerhealthconnect.tasker - -const val VARIABLE_NAME_DAYS = "days" -const val VARIABLE_NAME_DATA = "data" diff --git a/app/src/main/java/com/rafapps/taskerhealthconnect/tasker/GetHealthDataOutput.kt b/app/src/main/java/com/rafapps/taskerhealthconnect/tasker/GetHealthDataOutput.kt deleted file mode 100644 index 1076c66..0000000 --- a/app/src/main/java/com/rafapps/taskerhealthconnect/tasker/GetHealthDataOutput.kt +++ /dev/null @@ -1,16 +0,0 @@ -package com.rafapps.taskerhealthconnect.tasker - -import android.annotation.SuppressLint -import com.joaomgcd.taskerpluginlibrary.output.TaskerOutputObject -import com.joaomgcd.taskerpluginlibrary.output.TaskerOutputVariable -import com.rafapps.taskerhealthconnect.R - -@SuppressLint("NonConstantResourceId") -@TaskerOutputObject -class GetHealthDataOutput( - @get:TaskerOutputVariable( - name = VARIABLE_NAME_DATA, - labelResId = R.string.data, - htmlLabelResId = R.string.data - ) val data: String = "" -) diff --git a/app/src/main/res/layout/activity_get_data.xml b/app/src/main/res/layout/activity_aggregated_health_data.xml similarity index 76% rename from app/src/main/res/layout/activity_get_data.xml rename to app/src/main/res/layout/activity_aggregated_health_data.xml index 14247c7..657733d 100644 --- a/app/src/main/res/layout/activity_get_data.xml +++ b/app/src/main/res/layout/activity_aggregated_health_data.xml @@ -2,12 +2,12 @@ + tools:context=".aggregated.AggregatedHealthDataActivity"> +