diff --git a/packages/health/android/build.gradle b/packages/health/android/build.gradle index 5ba8fd6ec..d58d6540c 100644 --- a/packages/health/android/build.gradle +++ b/packages/health/android/build.gradle @@ -2,14 +2,14 @@ group 'cachet.plugins.health' version '1.2' buildscript { - ext.kotlin_version = '1.8.0' + ext.kotlin_version = '1.6.10' repositories { google() - mavenCentral() + jcenter() } dependencies { - classpath 'com.android.tools.build:gradle:7.4.0' + classpath 'com.android.tools.build:gradle:7.0.2' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" } } @@ -17,7 +17,7 @@ buildscript { rootProject.allprojects { repositories { google() - mavenCentral() + jcenter() } } @@ -25,14 +25,13 @@ apply plugin: 'com.android.library' apply plugin: 'kotlin-android' android { - compileSdkVersion 34 + compileSdkVersion 33 sourceSets { main.java.srcDirs += 'src/main/kotlin' } defaultConfig { - minSdkVersion 26 - targetSdkVersion 33 + minSdkVersion 16 testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } lintOptions { @@ -41,15 +40,7 @@ android { } dependencies { - def composeBom = platform('androidx.compose:compose-bom:2022.10.00') - implementation(composeBom) implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" implementation("com.google.android.gms:play-services-fitness:21.0.1") implementation("com.google.android.gms:play-services-auth:20.2.0") - - // The new health connect api - implementation("androidx.health.connect:connect-client:1.1.0-alpha06") - - def fragment_version = "1.6.2" - implementation "androidx.fragment:fragment-ktx:$fragment_version" } diff --git a/packages/health/android/gradle/wrapper/gradle-wrapper.properties b/packages/health/android/gradle/wrapper/gradle-wrapper.properties index 0e9a61051..35629eabb 100644 --- a/packages/health/android/gradle/wrapper/gradle-wrapper.properties +++ b/packages/health/android/gradle/wrapper/gradle-wrapper.properties @@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.5-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-7.0.2-all.zip diff --git a/packages/health/android/src/main/kotlin/cachet/plugins/health/Constants.kt b/packages/health/android/src/main/kotlin/cachet/plugins/health/Constants.kt deleted file mode 100644 index f0a4cd197..000000000 --- a/packages/health/android/src/main/kotlin/cachet/plugins/health/Constants.kt +++ /dev/null @@ -1,39 +0,0 @@ -package cachet.plugins.health - -internal const val SLEEP_ASLEEP = "SLEEP_ASLEEP" -internal const val SLEEP_AWAKE = "SLEEP_AWAKE" -internal const val SLEEP_IN_BED = "SLEEP_IN_BED" -internal const val SLEEP_SESSION = "SLEEP_SESSION" -internal const val SLEEP_LIGHT = "SLEEP_LIGHT" -internal const val SLEEP_DEEP = "SLEEP_DEEP" -internal const val SLEEP_REM = "SLEEP_REM" -internal const val SLEEP_OUT_OF_BED = "SLEEP_OUT_OF_BED" - -internal const val BODY_FAT_PERCENTAGE = "BODY_FAT_PERCENTAGE" -internal const val HEIGHT = "HEIGHT" -internal const val WEIGHT = "WEIGHT" -internal const val STEPS = "STEPS" -internal const val AGGREGATE_STEP_COUNT = "AGGREGATE_STEP_COUNT" -internal const val ACTIVE_ENERGY_BURNED = "ACTIVE_ENERGY_BURNED" -internal const val HEART_RATE = "HEART_RATE" -internal const val BODY_TEMPERATURE = "BODY_TEMPERATURE" -internal const val BLOOD_PRESSURE_SYSTOLIC = "BLOOD_PRESSURE_SYSTOLIC" -internal const val BLOOD_PRESSURE_DIASTOLIC = "BLOOD_PRESSURE_DIASTOLIC" -internal const val BLOOD_OXYGEN = "BLOOD_OXYGEN" -internal const val BLOOD_GLUCOSE = "BLOOD_GLUCOSE" -internal const val MOVE_MINUTES = "MOVE_MINUTES" -internal const val DISTANCE_DELTA = "DISTANCE_DELTA" -internal const val WATER = "WATER" -internal const val RESTING_HEART_RATE = "RESTING_HEART_RATE" -internal const val BASAL_ENERGY_BURNED = "BASAL_ENERGY_BURNED" -internal const val FLIGHTS_CLIMBED = "FLIGHTS_CLIMBED" -internal const val RESPIRATORY_RATE = "RESPIRATORY_RATE" -internal const val WORKOUT = "WORKOUT" -internal const val TOTAL_NUTRIENTS = "TOTAL_NUTRIENTS" -internal const val MENSTRUATION_DATA = "MENSTRUATION_DATA" - -const val GOOGLE_FIT_PERMISSIONS_REQUEST_CODE = 1111 - -const val CHANNEL_NAME = "flutter_health" -const val MMOLL_2_MGDL = 18.0 // 1 mmoll= 18 mgdl -const val LOGGER_CHANNEL_NAME = "flutter_health_logs_channel" diff --git a/packages/health/android/src/main/kotlin/cachet/plugins/health/HealthConnectService.kt b/packages/health/android/src/main/kotlin/cachet/plugins/health/HealthConnectService.kt deleted file mode 100644 index d7fb1541a..000000000 --- a/packages/health/android/src/main/kotlin/cachet/plugins/health/HealthConnectService.kt +++ /dev/null @@ -1,957 +0,0 @@ -package cachet.plugins.health - -import android.app.Activity -import android.content.Context -import android.util.Log -import androidx.activity.ComponentActivity -import androidx.activity.result.ActivityResultLauncher -import androidx.health.connect.client.HealthConnectClient -import androidx.health.connect.client.PermissionController -import androidx.health.connect.client.permission.HealthPermission -import androidx.health.connect.client.records.ActiveCaloriesBurnedRecord -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.DistanceRecord -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.HeightRecord -import androidx.health.connect.client.records.HydrationRecord -import androidx.health.connect.client.records.OxygenSaturationRecord -import androidx.health.connect.client.records.Record -import androidx.health.connect.client.records.RespiratoryRateRecord -import androidx.health.connect.client.records.RestingHeartRateRecord -import androidx.health.connect.client.records.SleepSessionRecord -import androidx.health.connect.client.records.SleepStageRecord -import androidx.health.connect.client.records.StepsRecord -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 androidx.health.connect.client.units.BloodGlucose -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.Percentage -import androidx.health.connect.client.units.Power -import androidx.health.connect.client.units.Pressure -import androidx.health.connect.client.units.Temperature -import androidx.health.connect.client.units.Volume -import io.flutter.plugin.common.MethodCall -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import java.time.Instant -import java.time.temporal.ChronoUnit - -class HealthConnectService { - - companion object { - private val mapSleepStageToType = hashMapOf( - 1 to SLEEP_AWAKE, - 2 to SLEEP_ASLEEP, - 3 to SLEEP_OUT_OF_BED, - 4 to SLEEP_LIGHT, - 5 to SLEEP_DEEP, - 6 to SLEEP_REM, - ) - - private val mapToHCType = hashMapOf( - BODY_FAT_PERCENTAGE to BodyFatRecord::class, - HEIGHT to HeightRecord::class, - WEIGHT to WeightRecord::class, - STEPS to StepsRecord::class, - AGGREGATE_STEP_COUNT to StepsRecord::class, - ACTIVE_ENERGY_BURNED to ActiveCaloriesBurnedRecord::class, - HEART_RATE to HeartRateRecord::class, - BODY_TEMPERATURE to BodyTemperatureRecord::class, - BLOOD_PRESSURE_SYSTOLIC to BloodPressureRecord::class, - BLOOD_PRESSURE_DIASTOLIC to BloodPressureRecord::class, - BLOOD_OXYGEN to OxygenSaturationRecord::class, - BLOOD_GLUCOSE to BloodGlucoseRecord::class, - DISTANCE_DELTA to DistanceRecord::class, - WATER to HydrationRecord::class, - SLEEP_ASLEEP to SleepStageRecord::class, - SLEEP_AWAKE to SleepStageRecord::class, - SLEEP_LIGHT to SleepStageRecord::class, - SLEEP_DEEP to SleepStageRecord::class, - SLEEP_REM to SleepStageRecord::class, - SLEEP_OUT_OF_BED to SleepStageRecord::class, - SLEEP_SESSION to SleepSessionRecord::class, - WORKOUT to ExerciseSessionRecord::class, - RESTING_HEART_RATE to RestingHeartRateRecord::class, - BASAL_ENERGY_BURNED to BasalMetabolicRateRecord::class, - FLIGHTS_CLIMBED to FloorsClimbedRecord::class, - RESPIRATORY_RATE to RespiratoryRateRecord::class, - // MOVE_MINUTES to TODO: Find alternative? - // TODO: Implement remaining types - // "ActiveCaloriesBurned" to ActiveCaloriesBurnedRecord::class, - // "BasalBodyTemperature" to BasalBodyTemperatureRecord::class, - // "BasalMetabolicRate" to BasalMetabolicRateRecord::class, - // "BloodGlucose" to BloodGlucoseRecord::class, - // "BloodPressure" to BloodPressureRecord::class, - // "BodyFat" to BodyFatRecord::class, - // "BodyTemperature" to BodyTemperatureRecord::class, - // "BoneMass" to BoneMassRecord::class, - // "CervicalMucus" to CervicalMucusRecord::class, - // "CyclingPedalingCadence" to CyclingPedalingCadenceRecord::class, - // "Distance" to DistanceRecord::class, - // "ElevationGained" to ElevationGainedRecord::class, - // "ExerciseSession" to ExerciseSessionRecord::class, - // "FloorsClimbed" to FloorsClimbedRecord::class, - // "HeartRate" to HeartRateRecord::class, - // "Height" to HeightRecord::class, - // "Hydration" to HydrationRecord::class, - // "LeanBodyMass" to LeanBodyMassRecord::class, - // "MenstruationFlow" to MenstruationFlowRecord::class, - // "MenstruationPeriod" to MenstruationPeriodRecord::class, - // "Nutrition" to NutritionRecord::class, - // "OvulationTest" to OvulationTestRecord::class, - // "OxygenSaturation" to OxygenSaturationRecord::class, - // "Power" to PowerRecord::class, - // "RespiratoryRate" to RespiratoryRateRecord::class, - // "RestingHeartRate" to RestingHeartRateRecord::class, - // "SexualActivity" to SexualActivityRecord::class, - // "SleepSession" to SleepSessionRecord::class, - // "SleepStage" to SleepStageRecord::class, - // "Speed" to SpeedRecord::class, - // "StepsCadence" to StepsCadenceRecord::class, - // "Steps" to StepsRecord::class, - // "TotalCaloriesBurned" to TotalCaloriesBurnedRecord::class, - // "Vo2Max" to Vo2MaxRecord::class, - // "Weight" to WeightRecord::class, - // "WheelchairPushes" to WheelchairPushesRecord::class, - ) - - // TODO: Update with new workout types when Health Connect becomes the standard. - val workoutTypeMapHealthConnect = mapOf( - // "AEROBICS" to ExerciseSessionRecord.EXERCISE_TYPE_AEROBICS, - "AMERICAN_FOOTBALL" to ExerciseSessionRecord.EXERCISE_TYPE_FOOTBALL_AMERICAN, - // "ARCHERY" to ExerciseSessionRecord.EXERCISE_TYPE_ARCHERY, - "AUSTRALIAN_FOOTBALL" to ExerciseSessionRecord.EXERCISE_TYPE_FOOTBALL_AUSTRALIAN, - "BADMINTON" to ExerciseSessionRecord.EXERCISE_TYPE_BADMINTON, - "BASEBALL" to ExerciseSessionRecord.EXERCISE_TYPE_BASEBALL, - "BASKETBALL" to ExerciseSessionRecord.EXERCISE_TYPE_BASKETBALL, - // "BIATHLON" to ExerciseSessionRecord.EXERCISE_TYPE_BIATHLON, - "BIKING" to ExerciseSessionRecord.EXERCISE_TYPE_BIKING, - // "BIKING_HAND" to ExerciseSessionRecord.EXERCISE_TYPE_BIKING_HAND, - // "BIKING_MOUNTAIN" to ExerciseSessionRecord.EXERCISE_TYPE_BIKING_MOUNTAIN, - // "BIKING_ROAD" to ExerciseSessionRecord.EXERCISE_TYPE_BIKING_ROAD, - // "BIKING_SPINNING" to ExerciseSessionRecord.EXERCISE_TYPE_BIKING_SPINNING, - // "BIKING_STATIONARY" to ExerciseSessionRecord.EXERCISE_TYPE_BIKING_STATIONARY, - // "BIKING_UTILITY" to ExerciseSessionRecord.EXERCISE_TYPE_BIKING_UTILITY, - "BOXING" to ExerciseSessionRecord.EXERCISE_TYPE_BOXING, - "CALISTHENICS" to ExerciseSessionRecord.EXERCISE_TYPE_CALISTHENICS, - // "CIRCUIT_TRAINING" to ExerciseSessionRecord.EXERCISE_TYPE_CIRCUIT_TRAINING, - "CRICKET" to ExerciseSessionRecord.EXERCISE_TYPE_CRICKET, - // "CROSS_COUNTRY_SKIING" to ExerciseSessionRecord.EXERCISE_TYPE_SKIING_CROSS_COUNTRY, - // "CROSS_FIT" to ExerciseSessionRecord.EXERCISE_TYPE_CROSSFIT, - // "CURLING" to ExerciseSessionRecord.EXERCISE_TYPE_CURLING, - "DANCING" to ExerciseSessionRecord.EXERCISE_TYPE_DANCING, - // "DIVING" to ExerciseSessionRecord.EXERCISE_TYPE_DIVING, - // "DOWNHILL_SKIING" to ExerciseSessionRecord.EXERCISE_TYPE_SKIING_DOWNHILL, - // "ELEVATOR" to ExerciseSessionRecord.EXERCISE_TYPE_ELEVATOR, - "ELLIPTICAL" to ExerciseSessionRecord.EXERCISE_TYPE_ELLIPTICAL, - // "ERGOMETER" to ExerciseSessionRecord.EXERCISE_TYPE_ERGOMETER, - // "ESCALATOR" to ExerciseSessionRecord.EXERCISE_TYPE_ESCALATOR, - "FENCING" to ExerciseSessionRecord.EXERCISE_TYPE_FENCING, - "FRISBEE_DISC" to ExerciseSessionRecord.EXERCISE_TYPE_FRISBEE_DISC, - // "GARDENING" to ExerciseSessionRecord.EXERCISE_TYPE_GARDENING, - "GOLF" to ExerciseSessionRecord.EXERCISE_TYPE_GOLF, - "GUIDED_BREATHING" to ExerciseSessionRecord.EXERCISE_TYPE_GUIDED_BREATHING, - "GYMNASTICS" to ExerciseSessionRecord.EXERCISE_TYPE_GYMNASTICS, - "HANDBALL" to ExerciseSessionRecord.EXERCISE_TYPE_HANDBALL, - "HIGH_INTENSITY_INTERVAL_TRAINING" to ExerciseSessionRecord.EXERCISE_TYPE_HIGH_INTENSITY_INTERVAL_TRAINING, - "HIKING" to ExerciseSessionRecord.EXERCISE_TYPE_HIKING, - // "HOCKEY" to ExerciseSessionRecord.EXERCISE_TYPE_HOCKEY, - // "HORSEBACK_RIDING" to ExerciseSessionRecord.EXERCISE_TYPE_HORSEBACK_RIDING, - // "HOUSEWORK" to ExerciseSessionRecord.EXERCISE_TYPE_HOUSEWORK, - // "IN_VEHICLE" to ExerciseSessionRecord.EXERCISE_TYPE_IN_VEHICLE, - "ICE_SKATING" to ExerciseSessionRecord.EXERCISE_TYPE_ICE_SKATING, - // "INTERVAL_TRAINING" to ExerciseSessionRecord.EXERCISE_TYPE_INTERVAL_TRAINING, - // "JUMP_ROPE" to ExerciseSessionRecord.EXERCISE_TYPE_JUMP_ROPE, - // "KAYAKING" to ExerciseSessionRecord.EXERCISE_TYPE_KAYAKING, - // "KETTLEBELL_TRAINING" to ExerciseSessionRecord.EXERCISE_TYPE_KETTLEBELL_TRAINING, - // "KICK_SCOOTER" to ExerciseSessionRecord.EXERCISE_TYPE_KICK_SCOOTER, - // "KICKBOXING" to ExerciseSessionRecord.EXERCISE_TYPE_KICKBOXING, - // "KITE_SURFING" to ExerciseSessionRecord.EXERCISE_TYPE_KITESURFING, - "MARTIAL_ARTS" to ExerciseSessionRecord.EXERCISE_TYPE_MARTIAL_ARTS, - // "MEDITATION" to ExerciseSessionRecord.EXERCISE_TYPE_MEDITATION, - // "MIXED_MARTIAL_ARTS" to ExerciseSessionRecord.EXERCISE_TYPE_MIXED_MARTIAL_ARTS, - // "P90X" to ExerciseSessionRecord.EXERCISE_TYPE_P90X, - "PARAGLIDING" to ExerciseSessionRecord.EXERCISE_TYPE_PARAGLIDING, - "PILATES" to ExerciseSessionRecord.EXERCISE_TYPE_PILATES, - // "POLO" to ExerciseSessionRecord.EXERCISE_TYPE_POLO, - "RACQUETBALL" to ExerciseSessionRecord.EXERCISE_TYPE_RACQUETBALL, - "ROCK_CLIMBING" to ExerciseSessionRecord.EXERCISE_TYPE_ROCK_CLIMBING, - "ROWING" to ExerciseSessionRecord.EXERCISE_TYPE_ROWING, - "ROWING_MACHINE" to ExerciseSessionRecord.EXERCISE_TYPE_ROWING_MACHINE, - "RUGBY" to ExerciseSessionRecord.EXERCISE_TYPE_RUGBY, - // "RUNNING_JOGGING" to ExerciseSessionRecord.EXERCISE_TYPE_RUNNING_JOGGING, - // "RUNNING_SAND" to ExerciseSessionRecord.EXERCISE_TYPE_RUNNING_SAND, - "RUNNING_TREADMILL" to ExerciseSessionRecord.EXERCISE_TYPE_RUNNING_TREADMILL, - "RUNNING" to ExerciseSessionRecord.EXERCISE_TYPE_RUNNING, - "SAILING" to ExerciseSessionRecord.EXERCISE_TYPE_SAILING, - "SCUBA_DIVING" to ExerciseSessionRecord.EXERCISE_TYPE_SCUBA_DIVING, - // "SKATING_CROSS" to ExerciseSessionRecord.EXERCISE_TYPE_SKATING_CROSS, - // "SKATING_INDOOR" to ExerciseSessionRecord.EXERCISE_TYPE_SKATING_INDOOR, - // "SKATING_INLINE" to ExerciseSessionRecord.EXERCISE_TYPE_SKATING_INLINE, - "SKATING" to ExerciseSessionRecord.EXERCISE_TYPE_SKATING, - "SKIING" to ExerciseSessionRecord.EXERCISE_TYPE_SKIING, - // "SKIING_BACK_COUNTRY" to ExerciseSessionRecord.EXERCISE_TYPE_SKIING_BACK_COUNTRY, - // "SKIING_KITE" to ExerciseSessionRecord.EXERCISE_TYPE_SKIING_KITE, - // "SKIING_ROLLER" to ExerciseSessionRecord.EXERCISE_TYPE_SKIING_ROLLER, - // "SLEDDING" to ExerciseSessionRecord.EXERCISE_TYPE_SLEDDING, - "SNOWBOARDING" to ExerciseSessionRecord.EXERCISE_TYPE_SNOWBOARDING, - // "SNOWMOBILE" to ExerciseSessionRecord.EXERCISE_TYPE_SNOWMOBILE, - "SNOWSHOEING" to ExerciseSessionRecord.EXERCISE_TYPE_SNOWSHOEING, - // "SOCCER" to ExerciseSessionRecord.EXERCISE_TYPE_FOOTBALL_SOCCER, - "SOFTBALL" to ExerciseSessionRecord.EXERCISE_TYPE_SOFTBALL, - "SQUASH" to ExerciseSessionRecord.EXERCISE_TYPE_SQUASH, - "STAIR_CLIMBING_MACHINE" to ExerciseSessionRecord.EXERCISE_TYPE_STAIR_CLIMBING_MACHINE, - "STAIR_CLIMBING" to ExerciseSessionRecord.EXERCISE_TYPE_STAIR_CLIMBING, - // "STANDUP_PADDLEBOARDING" to ExerciseSessionRecord.EXERCISE_TYPE_STANDUP_PADDLEBOARDING, - // "STILL" to ExerciseSessionRecord.EXERCISE_TYPE_STILL, - "STRENGTH_TRAINING" to ExerciseSessionRecord.EXERCISE_TYPE_STRENGTH_TRAINING, - "SURFING" to ExerciseSessionRecord.EXERCISE_TYPE_SURFING, - "SWIMMING_OPEN_WATER" to ExerciseSessionRecord.EXERCISE_TYPE_SWIMMING_OPEN_WATER, - "SWIMMING_POOL" to ExerciseSessionRecord.EXERCISE_TYPE_SWIMMING_POOL, - // "SWIMMING" to ExerciseSessionRecord.EXERCISE_TYPE_SWIMMING, - "TABLE_TENNIS" to ExerciseSessionRecord.EXERCISE_TYPE_TABLE_TENNIS, - // "TEAM_SPORTS" to ExerciseSessionRecord.EXERCISE_TYPE_TEAM_SPORTS, - "TENNIS" to ExerciseSessionRecord.EXERCISE_TYPE_TENNIS, - // "TILTING" to ExerciseSessionRecord.EXERCISE_TYPE_TILTING, - // "VOLLEYBALL_BEACH" to ExerciseSessionRecord.EXERCISE_TYPE_VOLLEYBALL_BEACH, - // "VOLLEYBALL_INDOOR" to ExerciseSessionRecord.EXERCISE_TYPE_VOLLEYBALL_INDOOR, - "VOLLEYBALL" to ExerciseSessionRecord.EXERCISE_TYPE_VOLLEYBALL, - // "WAKEBOARDING" to ExerciseSessionRecord.EXERCISE_TYPE_WAKEBOARDING, - // "WALKING_FITNESS" to ExerciseSessionRecord.EXERCISE_TYPE_WALKING_FITNESS, - // "WALKING_PACED" to ExerciseSessionRecord.EXERCISE_TYPE_WALKING_PACED, - // "WALKING_NORDIC" to ExerciseSessionRecord.EXERCISE_TYPE_WALKING_NORDIC, - // "WALKING_STROLLER" to ExerciseSessionRecord.EXERCISE_TYPE_WALKING_STROLLER, - // "WALKING_TREADMILL" to ExerciseSessionRecord.EXERCISE_TYPE_WALKING_TREADMILL, - "WALKING" to ExerciseSessionRecord.EXERCISE_TYPE_WALKING, - "WATER_POLO" to ExerciseSessionRecord.EXERCISE_TYPE_WATER_POLO, - "WEIGHTLIFTING" to ExerciseSessionRecord.EXERCISE_TYPE_WEIGHTLIFTING, - "WHEELCHAIR" to ExerciseSessionRecord.EXERCISE_TYPE_WHEELCHAIR, - // "WINDSURFING" to ExerciseSessionRecord.EXERCISE_TYPE_WINDSURFING, - "YOGA" to ExerciseSessionRecord.EXERCISE_TYPE_YOGA, - // "ZUMBA" to ExerciseSessionRecord.EXERCISE_TYPE_ZUMBA, - // "OTHER" to ExerciseSessionRecord.EXERCISE_TYPE_OTHER, - ) - } - - private var healthConnectClient: HealthConnectClient? = null - - private var healthConnectRequestPermissionsLauncher: ActivityResultLauncher>? = null - - fun initiate(activity: Activity, onHealthConnectPermissionCallback: (Set) -> Unit) { - if (isHealthConnectAvailable(activity)) { - healthConnectClient = HealthConnectClient.getOrCreate(activity) - - val requestPermissionActivityContract = PermissionController.createRequestPermissionResultContract() - healthConnectRequestPermissionsLauncher = - (activity as ComponentActivity).registerForActivityResult(requestPermissionActivityContract) { granted -> - onHealthConnectPermissionCallback(granted) - } - } - } - - fun isHealthConnectAvailable(context: Context?): Boolean { - context?.let { - return HealthConnectClient.getSdkStatus(it) == HealthConnectClient.SDK_AVAILABLE - } ?: Log.e("HealthConnectService", "Context is null, cannot check availability") - - return false - } - - suspend fun getData(call: MethodCall): List> = withContext(Dispatchers.IO) { - val dataType = call.argument("dataTypeKey")!! - val startTime = Instant.ofEpochMilli(call.argument("startTimeSec")!!) - val endTime = Instant.ofEpochMilli(call.argument("endTimeSec")!!) - - val healthConnectData = mutableListOf>() - - healthConnectClient?.let { healthConnectClient -> - mapToHCType[dataType]?.let { classType -> - val request = ReadRecordsRequest( - recordType = classType, - timeRangeFilter = TimeRangeFilter.between(startTime, endTime), - ) - val response = healthConnectClient.readRecords(request) - - // Workout needs distance and total calories burned too - if (dataType == WORKOUT) { - for (rec in response.records) { - val record = rec as ExerciseSessionRecord - val distanceRequest = healthConnectClient.readRecords( - ReadRecordsRequest( - recordType = DistanceRecord::class, - timeRangeFilter = TimeRangeFilter.between( - record.startTime, - record.endTime, - ), - ), - ) - var totalDistance = 0.0 - for (distanceRec in distanceRequest.records) { - totalDistance += distanceRec.distance.inMeters - } - - val energyBurnedRequest = healthConnectClient.readRecords( - ReadRecordsRequest( - recordType = TotalCaloriesBurnedRecord::class, - timeRangeFilter = TimeRangeFilter.between( - record.startTime, - record.endTime, - ), - ), - ) - var totalEnergyBurned = 0.0 - for (energyBurnedRec in energyBurnedRequest.records) { - totalEnergyBurned += energyBurnedRec.energy.inKilocalories - } - - healthConnectData.add( - // mapOf( - mapOf( - "workoutActivityType" to (workoutTypeMapHealthConnect.filterValues { it == record.exerciseType }.keys.firstOrNull() - ?: "OTHER"), - "totalDistance" to if (totalDistance == 0.0) null else totalDistance, - "totalDistanceUnit" to "METER", - "totalEnergyBurned" to if (totalEnergyBurned == 0.0) null else totalEnergyBurned, - "totalEnergyBurnedUnit" to "KILOCALORIE", - "unit" to "MINUTES", - "date_from" to rec.startTime.toEpochMilli(), - "date_to" to rec.endTime.toEpochMilli(), - "source_id" to "", - "source_name" to record.metadata.dataOrigin.packageName, - ), - ) - } - // Filter sleep stages for requested stage - } else if (classType == SleepStageRecord::class) { - for (rec in response.records) { - if (rec is SleepStageRecord) { - if (dataType == mapSleepStageToType[rec.stage]) { - healthConnectData.addAll(convertRecord(rec, dataType)) - } - } - } - } else { - for (rec in response.records) { - healthConnectData.addAll(convertRecord(rec, dataType)) - } - } - } - } ?: Log.e("HealthConnectService", "HealthConnectClient is null, cannot get data") - - return@withContext healthConnectData - } - - suspend fun hasPermissions(call: MethodCall): Boolean = withContext(Dispatchers.IO) { - healthConnectClient?.let { - - val args = call.arguments as HashMap<*, *> - val types = (args["types"] as? ArrayList<*>)?.filterIsInstance()!! - val permissions = (args["permissions"] as? ArrayList<*>)?.filterIsInstance()!! - - val permList = mutableListOf() - for ((i, typeKey) in types.withIndex()) { - val access = permissions[i] - val dataType = mapToHCType[typeKey]!! - if (access == 0) { - permList.add( - HealthPermission.getReadPermission(dataType), - ) - } else { - permList.addAll( - listOf( - HealthPermission.getReadPermission(dataType), - HealthPermission.getWritePermission(dataType), - ), - ) - } - // Workout also needs distance and total energy burned too - if (typeKey == WORKOUT) { - if (access == 0) { - permList.addAll( - listOf( - HealthPermission.getReadPermission(DistanceRecord::class), - HealthPermission.getReadPermission(TotalCaloriesBurnedRecord::class), - ), - ) - } else { - permList.addAll( - listOf( - HealthPermission.getReadPermission(DistanceRecord::class), - HealthPermission.getReadPermission(TotalCaloriesBurnedRecord::class), - HealthPermission.getWritePermission(DistanceRecord::class), - HealthPermission.getWritePermission(TotalCaloriesBurnedRecord::class), - ), - ) - } - } - } - return@withContext it.permissionController - .getGrantedPermissions() - .containsAll(permList) - - } ?: Log.e("HealthConnectService", "HealthConnectClient is null, cannot check permissions") - - return@withContext false - } - - suspend fun writeWorkoutData(call: MethodCall): Boolean = withContext(Dispatchers.IO) { - val type = call.argument("activityType")!! - val startTime = Instant.ofEpochMilli(call.argument("startTimeSec")!!) - val endTime = Instant.ofEpochMilli(call.argument("endTimeSec")!!) - val totalEnergyBurned = call.argument("totalEnergyBurned") - val totalDistance = call.argument("totalDistance") - val workoutType = workoutTypeMapHealthConnect[type]!! - - try { - val list = mutableListOf() - list.add( - ExerciseSessionRecord( - startTime = startTime, - startZoneOffset = null, - endTime = endTime, - endZoneOffset = null, - exerciseType = workoutType, - title = type, - ), - ) - if (totalDistance != null) { - list.add( - DistanceRecord( - startTime = startTime, - startZoneOffset = null, - endTime = endTime, - endZoneOffset = null, - distance = Length.meters(totalDistance.toDouble()), - ), - ) - } - if (totalEnergyBurned != null) { - list.add( - TotalCaloriesBurnedRecord( - startTime = startTime, - startZoneOffset = null, - endTime = endTime, - endZoneOffset = null, - energy = Energy.kilocalories(totalEnergyBurned.toDouble()), - ), - ) - } - healthConnectClient?.insertRecords( - list, - ) - Log.i("FLUTTER_HEALTH::SUCCESS", "[Health Connect] Workout was successfully added!") - return@withContext true - } catch (e: Exception) { - Log.w( - "FLUTTER_HEALTH::ERROR", - "[Health Connect] There was an error adding the workout", - ) - Log.w("FLUTTER_HEALTH::ERROR", e.message ?: "unknown error") - Log.w("FLUTTER_HEALTH::ERROR", e.stackTrace.toString()) - return@withContext false - } - } - - - suspend fun writeBloodPressure(call: MethodCall): Boolean = withContext(Dispatchers.IO) { - val systolic = call.argument("systolic")!! - val diastolic = call.argument("diastolic")!! - val startTime = Instant.ofEpochMilli(call.argument("startTimeSec")!!) - - try { - healthConnectClient?.insertRecords( - listOf( - BloodPressureRecord( - time = startTime, - systolic = Pressure.millimetersOfMercury(systolic), - diastolic = Pressure.millimetersOfMercury(diastolic), - zoneOffset = null, - ), - ), - ) - Log.i( - "FLUTTER_HEALTH::SUCCESS", - "[Health Connect] Blood pressure was successfully added!", - ) - return@withContext true - } catch (e: Exception) { - Log.w( - "FLUTTER_HEALTH::ERROR", - "[Health Connect] There was an error adding the blood pressure", - ) - Log.w("FLUTTER_HEALTH::ERROR", e.message ?: "unknown error") - Log.w("FLUTTER_HEALTH::ERROR", e.stackTrace.toString()) - return@withContext false - } - } - - suspend fun deleteData(call: MethodCall): Boolean = withContext(Dispatchers.IO) { - val type = call.argument("dataTypeKey")!! - val startTime = Instant.ofEpochMilli(call.argument("startTimeSec")!!) - val endTime = Instant.ofEpochMilli(call.argument("endTimeSec")!!) - val classType = mapToHCType[type]!! - - try { - healthConnectClient?.deleteRecords( - recordType = classType, - timeRangeFilter = TimeRangeFilter.between(startTime, endTime), - ) - return@withContext true - } catch (e: Exception) { - Log.e("FLUTTER_HEALTH::ERROR", "[Health Connect] There was an error deleting the data") - return@withContext false - } - } - - suspend fun writeData(call: MethodCall): Boolean = withContext(Dispatchers.IO) { - val type = call.argument("dataTypeKey")!! - val startTime = call.argument("startTimeSec")!! - val endTime = call.argument("endTimeSec")!! - val value = call.argument("value")!! - val record = when (type) { - BODY_FAT_PERCENTAGE -> BodyFatRecord( - time = Instant.ofEpochMilli(startTime), - percentage = Percentage(value), - zoneOffset = null, - ) - - HEIGHT -> HeightRecord( - time = Instant.ofEpochMilli(startTime), - height = Length.meters(value), - zoneOffset = null, - ) - - WEIGHT -> WeightRecord( - time = Instant.ofEpochMilli(startTime), - weight = Mass.kilograms(value), - zoneOffset = null, - ) - - STEPS -> StepsRecord( - startTime = Instant.ofEpochMilli(startTime), - endTime = Instant.ofEpochMilli(endTime), - count = value.toLong(), - startZoneOffset = null, - endZoneOffset = null, - ) - - ACTIVE_ENERGY_BURNED -> ActiveCaloriesBurnedRecord( - startTime = Instant.ofEpochMilli(startTime), - endTime = Instant.ofEpochMilli(endTime), - energy = Energy.kilocalories(value), - startZoneOffset = null, - endZoneOffset = null, - ) - - HEART_RATE -> HeartRateRecord( - startTime = Instant.ofEpochMilli(startTime), - endTime = Instant.ofEpochMilli(endTime), - samples = listOf( - HeartRateRecord.Sample( - time = Instant.ofEpochMilli(startTime), - beatsPerMinute = value.toLong(), - ), - ), - startZoneOffset = null, - endZoneOffset = null, - ) - - BODY_TEMPERATURE -> BodyTemperatureRecord( - time = Instant.ofEpochMilli(startTime), - temperature = Temperature.celsius(value), - zoneOffset = null, - ) - - BLOOD_OXYGEN -> OxygenSaturationRecord( - time = Instant.ofEpochMilli(startTime), - percentage = Percentage(value), - zoneOffset = null, - ) - - BLOOD_GLUCOSE -> BloodGlucoseRecord( - time = Instant.ofEpochMilli(startTime), - level = BloodGlucose.milligramsPerDeciliter(value), - zoneOffset = null, - ) - - DISTANCE_DELTA -> DistanceRecord( - startTime = Instant.ofEpochMilli(startTime), - endTime = Instant.ofEpochMilli(endTime), - distance = Length.meters(value), - startZoneOffset = null, - endZoneOffset = null, - ) - - WATER -> HydrationRecord( - startTime = Instant.ofEpochMilli(startTime), - endTime = Instant.ofEpochMilli(endTime), - volume = Volume.liters(value), - startZoneOffset = null, - endZoneOffset = null, - ) - - SLEEP_ASLEEP -> SleepStageRecord( - startTime = Instant.ofEpochMilli(startTime), - endTime = Instant.ofEpochMilli(endTime), - startZoneOffset = null, - endZoneOffset = null, - stage = SleepStageRecord.STAGE_TYPE_SLEEPING, - ) - - SLEEP_LIGHT -> SleepStageRecord( - startTime = Instant.ofEpochMilli(startTime), - endTime = Instant.ofEpochMilli(endTime), - startZoneOffset = null, - endZoneOffset = null, - stage = SleepStageRecord.STAGE_TYPE_LIGHT, - ) - - SLEEP_DEEP -> SleepStageRecord( - startTime = Instant.ofEpochMilli(startTime), - endTime = Instant.ofEpochMilli(endTime), - startZoneOffset = null, - endZoneOffset = null, - stage = SleepStageRecord.STAGE_TYPE_DEEP, - ) - - SLEEP_REM -> SleepStageRecord( - startTime = Instant.ofEpochMilli(startTime), - endTime = Instant.ofEpochMilli(endTime), - startZoneOffset = null, - endZoneOffset = null, - stage = SleepStageRecord.STAGE_TYPE_REM, - ) - - SLEEP_OUT_OF_BED -> SleepStageRecord( - startTime = Instant.ofEpochMilli(startTime), - endTime = Instant.ofEpochMilli(endTime), - startZoneOffset = null, - endZoneOffset = null, - stage = SleepStageRecord.STAGE_TYPE_OUT_OF_BED, - ) - - SLEEP_AWAKE -> SleepStageRecord( - startTime = Instant.ofEpochMilli(startTime), - endTime = Instant.ofEpochMilli(endTime), - startZoneOffset = null, - endZoneOffset = null, - stage = SleepStageRecord.STAGE_TYPE_AWAKE, - ) - - - SLEEP_SESSION -> SleepSessionRecord( - startTime = Instant.ofEpochMilli(startTime), - endTime = Instant.ofEpochMilli(endTime), - startZoneOffset = null, - endZoneOffset = null, - ) - - RESTING_HEART_RATE -> RestingHeartRateRecord( - time = Instant.ofEpochMilli(startTime), - beatsPerMinute = value.toLong(), - zoneOffset = null, - ) - - BASAL_ENERGY_BURNED -> BasalMetabolicRateRecord( - time = Instant.ofEpochMilli(startTime), - basalMetabolicRate = Power.kilocaloriesPerDay(value), - zoneOffset = null, - ) - - FLIGHTS_CLIMBED -> FloorsClimbedRecord( - startTime = Instant.ofEpochMilli(startTime), - endTime = Instant.ofEpochMilli(endTime), - floors = value, - startZoneOffset = null, - endZoneOffset = null, - ) - - RESPIRATORY_RATE -> RespiratoryRateRecord( - time = Instant.ofEpochMilli(startTime), - rate = value, - zoneOffset = null, - ) - // AGGREGATE_STEP_COUNT -> StepsRecord() - BLOOD_PRESSURE_SYSTOLIC -> throw IllegalArgumentException("You must use the [writeBloodPressure] API ") - BLOOD_PRESSURE_DIASTOLIC -> throw IllegalArgumentException("You must use the [writeBloodPressure] API ") - WORKOUT -> throw IllegalArgumentException("You must use the [writeWorkoutData] API ") - else -> throw IllegalArgumentException("The type $type was not supported by the Health plugin or you must use another API ") - } - try { - healthConnectClient?.insertRecords(listOf(record)) - return@withContext true - } catch (e: Exception) { - Log.e("XXX", "[Health Connect] There was an error adding the data", e) - return@withContext false - } - } - - suspend fun getSteps(start: Long, end: Long): Long? = withContext(Dispatchers.IO) { - try { - val startInstant = Instant.ofEpochMilli(start) - val endInstant = Instant.ofEpochMilli(end) - val response = healthConnectClient?.aggregate( - AggregateRequest( - metrics = setOf(StepsRecord.COUNT_TOTAL), - timeRangeFilter = TimeRangeFilter.between(startInstant, endInstant), - ), - ) - // The result may be null if no data is available in the time range. - val stepsInInterval = response?.get(StepsRecord.COUNT_TOTAL) ?: 0L - Log.i("FLUTTER_HEALTH::SUCCESS", "returning $stepsInInterval steps") - return@withContext stepsInInterval - } catch (e: Exception) { - Log.i("FLUTTER_HEALTH::ERROR", "unable to return steps") - return@withContext null - } - } - - fun requestAuthorization(call: MethodCall) { - val args = call.arguments as java.util.HashMap<*, *> - val types = (args["types"] as? java.util.ArrayList<*>)?.filterIsInstance()!! - val permissions = (args["permissions"] as? java.util.ArrayList<*>)?.filterIsInstance()!! - - val permList = mutableListOf() - for ((i, typeKey) in types.withIndex()) { - val access = permissions[i] - val dataType = mapToHCType[typeKey]!! - if (access == 0) { - permList.add( - HealthPermission.getReadPermission(dataType), - ) - } else { - permList.addAll( - listOf( - HealthPermission.getReadPermission(dataType), - HealthPermission.getWritePermission(dataType), - ), - ) - } - // Workout also needs distance and total energy burned too - if (typeKey == WORKOUT) { - if (access == 0) { - permList.addAll( - listOf( - HealthPermission.getReadPermission(DistanceRecord::class), - HealthPermission.getReadPermission(TotalCaloriesBurnedRecord::class), - ), - ) - } else { - permList.addAll( - listOf( - HealthPermission.getReadPermission(DistanceRecord::class), - HealthPermission.getReadPermission(TotalCaloriesBurnedRecord::class), - HealthPermission.getWritePermission(DistanceRecord::class), - HealthPermission.getWritePermission(TotalCaloriesBurnedRecord::class), - ), - ) - } - } - } - - healthConnectRequestPermissionsLauncher?.launch(permList.toSet()) - } - - private fun convertRecord(record: Any, dataType: String): List> { - val metadata = (record as Record).metadata - when (record) { - is WeightRecord -> return listOf( - mapOf( - "value" to record.weight.inKilograms, - "date_from" to record.time.toEpochMilli(), - "date_to" to record.time.toEpochMilli(), - "source_id" to "", - "source_name" to metadata.dataOrigin.packageName, - ), - ) - - is HeightRecord -> return listOf( - mapOf( - "value" to record.height.inMeters, - "date_from" to record.time.toEpochMilli(), - "date_to" to record.time.toEpochMilli(), - "source_id" to "", - "source_name" to metadata.dataOrigin.packageName, - ), - ) - - is BodyFatRecord -> return listOf( - mapOf( - "value" to record.percentage.value, - "date_from" to record.time.toEpochMilli(), - "date_to" to record.time.toEpochMilli(), - "source_id" to "", - "source_name" to metadata.dataOrigin.packageName, - ), - ) - - is StepsRecord -> return listOf( - mapOf( - "value" to record.count, - "date_from" to record.startTime.toEpochMilli(), - "date_to" to record.endTime.toEpochMilli(), - "source_id" to "", - "source_name" to metadata.dataOrigin.packageName, - ), - ) - - is ActiveCaloriesBurnedRecord -> return listOf( - mapOf( - "value" to record.energy.inKilocalories, - "date_from" to record.startTime.toEpochMilli(), - "date_to" to record.endTime.toEpochMilli(), - "source_id" to "", - "source_name" to metadata.dataOrigin.packageName, - ), - ) - - is HeartRateRecord -> return record.samples.map { - mapOf( - "value" to it.beatsPerMinute, - "date_from" to it.time.toEpochMilli(), - "date_to" to it.time.toEpochMilli(), - "source_id" to "", - "source_name" to metadata.dataOrigin.packageName, - ) - } - - is BodyTemperatureRecord -> return listOf( - mapOf( - "value" to record.temperature.inCelsius, - "date_from" to record.time.toEpochMilli(), - "date_to" to record.time.toEpochMilli(), - "source_id" to "", - "source_name" to metadata.dataOrigin.packageName, - ), - ) - - is BloodPressureRecord -> return listOf( - mapOf( - "value" to if (dataType == BLOOD_PRESSURE_DIASTOLIC) record.diastolic.inMillimetersOfMercury else record.systolic.inMillimetersOfMercury, - "date_from" to record.time.toEpochMilli(), - "date_to" to record.time.toEpochMilli(), - "source_id" to "", - "source_name" to metadata.dataOrigin.packageName, - ), - ) - - is OxygenSaturationRecord -> return listOf( - mapOf( - "value" to record.percentage.value, - "date_from" to record.time.toEpochMilli(), - "date_to" to record.time.toEpochMilli(), - "source_id" to "", - "source_name" to metadata.dataOrigin.packageName, - ), - ) - - is BloodGlucoseRecord -> return listOf( - mapOf( - "value" to record.level.inMilligramsPerDeciliter, - "date_from" to record.time.toEpochMilli(), - "date_to" to record.time.toEpochMilli(), - "source_id" to "", - "source_name" to metadata.dataOrigin.packageName, - ), - ) - - is DistanceRecord -> return listOf( - mapOf( - "value" to record.distance.inMeters, - "date_from" to record.startTime.toEpochMilli(), - "date_to" to record.endTime.toEpochMilli(), - "source_id" to "", - "source_name" to metadata.dataOrigin.packageName, - ), - ) - - is HydrationRecord -> return listOf( - mapOf( - "value" to record.volume.inLiters, - "date_from" to record.startTime.toEpochMilli(), - "date_to" to record.endTime.toEpochMilli(), - "source_id" to "", - "source_name" to metadata.dataOrigin.packageName, - ), - ) - - is SleepSessionRecord -> return listOf( - mapOf( - "date_from" to record.startTime.toEpochMilli(), - "date_to" to record.endTime.toEpochMilli(), - "value" to ChronoUnit.MINUTES.between(record.startTime, record.endTime), - "source_id" to "", - "source_name" to metadata.dataOrigin.packageName, - ), - ) - - is SleepStageRecord -> return listOf( - mapOf( - "stage" to record.stage, - "value" to ChronoUnit.MINUTES.between(record.startTime, record.endTime), - "date_from" to record.startTime.toEpochMilli(), - "date_to" to record.endTime.toEpochMilli(), - "source_id" to "", - "source_name" to metadata.dataOrigin.packageName, - ), - ) - - is RestingHeartRateRecord -> return listOf( - mapOf( - "value" to record.beatsPerMinute, - "date_from" to record.time.toEpochMilli(), - "date_to" to record.time.toEpochMilli(), - "source_id" to "", - "source_name" to metadata.dataOrigin.packageName, - ) - ) - - is BasalMetabolicRateRecord -> return listOf( - mapOf( - "value" to record.basalMetabolicRate.inKilocaloriesPerDay, - "date_from" to record.time.toEpochMilli(), - "date_to" to record.time.toEpochMilli(), - "source_id" to "", - "source_name" to metadata.dataOrigin.packageName, - ) - ) - - is FloorsClimbedRecord -> return listOf( - mapOf( - "value" to record.floors, - "date_from" to record.startTime.toEpochMilli(), - "date_to" to record.endTime.toEpochMilli(), - "source_id" to "", - "source_name" to metadata.dataOrigin.packageName, - ) - ) - - is RespiratoryRateRecord -> return listOf( - mapOf( - "value" to record.rate, - "date_from" to record.time.toEpochMilli(), - "date_to" to record.time.toEpochMilli(), - "source_id" to "", - "source_name" to metadata.dataOrigin.packageName, - ) - ) - - else -> throw IllegalArgumentException("Health data type not supported") - } - } - - fun clear() { - healthConnectClient = null - } -} \ No newline at end of file diff --git a/packages/health/android/src/main/kotlin/cachet/plugins/health/HealthPlugin.kt b/packages/health/android/src/main/kotlin/cachet/plugins/health/HealthPlugin.kt index dcef97c0a..ecfa9fe15 100644 --- a/packages/health/android/src/main/kotlin/cachet/plugins/health/HealthPlugin.kt +++ b/packages/health/android/src/main/kotlin/cachet/plugins/health/HealthPlugin.kt @@ -1,23 +1,16 @@ package cachet.plugins.health -// import androidx.compose.runtime.mutableStateOf - -// Health Connect import android.app.Activity -import android.content.Context import android.content.Intent import android.content.pm.PackageManager import android.os.Handler import android.util.Log import androidx.core.content.ContextCompat -import androidx.health.connect.client.records.* -import androidx.health.connect.client.units.* import com.google.android.gms.auth.api.signin.GoogleSignIn import com.google.android.gms.fitness.Fitness import com.google.android.gms.fitness.FitnessActivities import com.google.android.gms.fitness.FitnessOptions import com.google.android.gms.fitness.data.* -import com.google.android.gms.fitness.request.DataDeleteRequest import com.google.android.gms.fitness.request.DataReadRequest import com.google.android.gms.fitness.request.SessionInsertRequest import com.google.android.gms.fitness.request.SessionReadRequest @@ -35,1236 +28,983 @@ import io.flutter.plugin.common.MethodChannel.MethodCallHandler import io.flutter.plugin.common.MethodChannel.Result import io.flutter.plugin.common.PluginRegistry.ActivityResultListener import io.flutter.plugin.common.PluginRegistry.Registrar -import kotlinx.coroutines.* import java.text.SimpleDateFormat -import java.time.* import java.util.* import java.util.concurrent.* - -class HealthPlugin( - private var channel: MethodChannel? = null, -) : - MethodCallHandler, - ActivityResultListener, - EventChannel.StreamHandler, - ActivityAware, - FlutterPlugin { - private var mResult: Result? = null - private var activity: Activity? = null - private var context: Context? = null - - private var threadPoolExecutor: ExecutorService? = null - private var useHealthConnectIfAvailable: Boolean = false - private lateinit var mainScope: CoroutineScope - - private var logsChannel: EventChannel? = null - private var logger: EventChannel.EventSink? = null - - private var iso8601DateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ") - - private val healthConnectService: HealthConnectService = HealthConnectService() - - private val workoutTypeMap = mapOf( - "AEROBICS" to FitnessActivities.AEROBICS, - "AMERICAN_FOOTBALL" to FitnessActivities.FOOTBALL_AMERICAN, - "ARCHERY" to FitnessActivities.ARCHERY, - "AUSTRALIAN_FOOTBALL" to FitnessActivities.FOOTBALL_AUSTRALIAN, - "BADMINTON" to FitnessActivities.BADMINTON, - "BASEBALL" to FitnessActivities.BASEBALL, - "BASKETBALL" to FitnessActivities.BASKETBALL, - "BIATHLON" to FitnessActivities.BIATHLON, - "BIKING" to FitnessActivities.BIKING, - "BIKING_HAND" to FitnessActivities.BIKING_HAND, - "BIKING_MOUNTAIN" to FitnessActivities.BIKING_MOUNTAIN, - "BIKING_ROAD" to FitnessActivities.BIKING_ROAD, - "BIKING_SPINNING" to FitnessActivities.BIKING_SPINNING, - "BIKING_STATIONARY" to FitnessActivities.BIKING_STATIONARY, - "BIKING_UTILITY" to FitnessActivities.BIKING_UTILITY, - "BOXING" to FitnessActivities.BOXING, - "CALISTHENICS" to FitnessActivities.CALISTHENICS, - "CIRCUIT_TRAINING" to FitnessActivities.CIRCUIT_TRAINING, - "CRICKET" to FitnessActivities.CRICKET, - "CROSS_COUNTRY_SKIING" to FitnessActivities.SKIING_CROSS_COUNTRY, - "CROSS_FIT" to FitnessActivities.CROSSFIT, - "CURLING" to FitnessActivities.CURLING, - "DANCING" to FitnessActivities.DANCING, - "DIVING" to FitnessActivities.DIVING, - "DOWNHILL_SKIING" to FitnessActivities.SKIING_DOWNHILL, - "ELEVATOR" to FitnessActivities.ELEVATOR, - "ELLIPTICAL" to FitnessActivities.ELLIPTICAL, - "ERGOMETER" to FitnessActivities.ERGOMETER, - "ESCALATOR" to FitnessActivities.ESCALATOR, - "FENCING" to FitnessActivities.FENCING, - "FRISBEE_DISC" to FitnessActivities.FRISBEE_DISC, - "GARDENING" to FitnessActivities.GARDENING, - "GOLF" to FitnessActivities.GOLF, - "GUIDED_BREATHING" to FitnessActivities.GUIDED_BREATHING, - "GYMNASTICS" to FitnessActivities.GYMNASTICS, - "HANDBALL" to FitnessActivities.HANDBALL, - "HIGH_INTENSITY_INTERVAL_TRAINING" to FitnessActivities.HIGH_INTENSITY_INTERVAL_TRAINING, - "HIKING" to FitnessActivities.HIKING, - "HOCKEY" to FitnessActivities.HOCKEY, - "HORSEBACK_RIDING" to FitnessActivities.HORSEBACK_RIDING, - "HOUSEWORK" to FitnessActivities.HOUSEWORK, - "IN_VEHICLE" to FitnessActivities.IN_VEHICLE, - "ICE_SKATING" to FitnessActivities.ICE_SKATING, - "INTERVAL_TRAINING" to FitnessActivities.INTERVAL_TRAINING, - "JUMP_ROPE" to FitnessActivities.JUMP_ROPE, - "KAYAKING" to FitnessActivities.KAYAKING, - "KETTLEBELL_TRAINING" to FitnessActivities.KETTLEBELL_TRAINING, - "KICK_SCOOTER" to FitnessActivities.KICK_SCOOTER, - "KICKBOXING" to FitnessActivities.KICKBOXING, - "KITE_SURFING" to FitnessActivities.KITESURFING, - "MARTIAL_ARTS" to FitnessActivities.MARTIAL_ARTS, - "MEDITATION" to FitnessActivities.MEDITATION, - "MIXED_MARTIAL_ARTS" to FitnessActivities.MIXED_MARTIAL_ARTS, - "P90X" to FitnessActivities.P90X, - "PARAGLIDING" to FitnessActivities.PARAGLIDING, - "PILATES" to FitnessActivities.PILATES, - "POLO" to FitnessActivities.POLO, - "RACQUETBALL" to FitnessActivities.RACQUETBALL, - "ROCK_CLIMBING" to FitnessActivities.ROCK_CLIMBING, - "ROWING" to FitnessActivities.ROWING, - "ROWING_MACHINE" to FitnessActivities.ROWING_MACHINE, - "RUGBY" to FitnessActivities.RUGBY, - "RUNNING_JOGGING" to FitnessActivities.RUNNING_JOGGING, - "RUNNING_SAND" to FitnessActivities.RUNNING_SAND, - "RUNNING_TREADMILL" to FitnessActivities.RUNNING_TREADMILL, - "RUNNING" to FitnessActivities.RUNNING, - "SAILING" to FitnessActivities.SAILING, - "SCUBA_DIVING" to FitnessActivities.SCUBA_DIVING, - "SKATING_CROSS" to FitnessActivities.SKATING_CROSS, - "SKATING_INDOOR" to FitnessActivities.SKATING_INDOOR, - "SKATING_INLINE" to FitnessActivities.SKATING_INLINE, - "SKATING" to FitnessActivities.SKATING, - "SKIING" to FitnessActivities.SKIING, - "SKIING_BACK_COUNTRY" to FitnessActivities.SKIING_BACK_COUNTRY, - "SKIING_KITE" to FitnessActivities.SKIING_KITE, - "SKIING_ROLLER" to FitnessActivities.SKIING_ROLLER, - "SLEDDING" to FitnessActivities.SLEDDING, - "SNOWBOARDING" to FitnessActivities.SNOWBOARDING, - "SNOWMOBILE" to FitnessActivities.SNOWMOBILE, - "SNOWSHOEING" to FitnessActivities.SNOWSHOEING, - "SOCCER" to FitnessActivities.FOOTBALL_SOCCER, - "SOFTBALL" to FitnessActivities.SOFTBALL, - "SQUASH" to FitnessActivities.SQUASH, - "STAIR_CLIMBING_MACHINE" to FitnessActivities.STAIR_CLIMBING_MACHINE, - "STAIR_CLIMBING" to FitnessActivities.STAIR_CLIMBING, - "STANDUP_PADDLEBOARDING" to FitnessActivities.STANDUP_PADDLEBOARDING, - "STILL" to FitnessActivities.STILL, - "STRENGTH_TRAINING" to FitnessActivities.STRENGTH_TRAINING, - "SURFING" to FitnessActivities.SURFING, - "SWIMMING_OPEN_WATER" to FitnessActivities.SWIMMING_OPEN_WATER, - "SWIMMING_POOL" to FitnessActivities.SWIMMING_POOL, - "SWIMMING" to FitnessActivities.SWIMMING, - "TABLE_TENNIS" to FitnessActivities.TABLE_TENNIS, - "TEAM_SPORTS" to FitnessActivities.TEAM_SPORTS, - "TENNIS" to FitnessActivities.TENNIS, - "TILTING" to FitnessActivities.TILTING, - "VOLLEYBALL_BEACH" to FitnessActivities.VOLLEYBALL_BEACH, - "VOLLEYBALL_INDOOR" to FitnessActivities.VOLLEYBALL_INDOOR, - "VOLLEYBALL" to FitnessActivities.VOLLEYBALL, - "WAKEBOARDING" to FitnessActivities.WAKEBOARDING, - "WALKING_FITNESS" to FitnessActivities.WALKING_FITNESS, - "WALKING_NORDIC" to FitnessActivities.WALKING_NORDIC, - "WALKING_STROLLER" to FitnessActivities.WALKING_STROLLER, - "WALKING_TREADMILL" to FitnessActivities.WALKING_TREADMILL, - "WALKING" to FitnessActivities.WALKING, - "WATER_POLO" to FitnessActivities.WATER_POLO, - "WEIGHTLIFTING" to FitnessActivities.WEIGHTLIFTING, - "WHEELCHAIR" to FitnessActivities.WHEELCHAIR, - "WINDSURFING" to FitnessActivities.WINDSURFING, - "YOGA" to FitnessActivities.YOGA, - "ZUMBA" to FitnessActivities.ZUMBA, - "OTHER" to FitnessActivities.OTHER, - ) - - override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { - mainScope = CoroutineScope(SupervisorJob() + Dispatchers.Main) - channel = MethodChannel(flutterPluginBinding.binaryMessenger, CHANNEL_NAME) - channel?.setMethodCallHandler(this) - context = flutterPluginBinding.applicationContext - threadPoolExecutor = Executors.newFixedThreadPool(4) - logsChannel = EventChannel(flutterPluginBinding.binaryMessenger, LOGGER_CHANNEL_NAME) - logsChannel?.setStreamHandler(this) +const val GOOGLE_FIT_PERMISSIONS_REQUEST_CODE = 1111 +const val CHANNEL_NAME = "flutter_health" +const val LOGGER_CHANNEL_NAME = "flutter_health_logs_channel" +const val MMOLL_2_MGDL = 18.0 // 1 mmoll= 18 mgdl + +class HealthPlugin(private var channel: MethodChannel? = null) : MethodCallHandler, + EventChannel.StreamHandler, + ActivityResultListener, Result, ActivityAware, FlutterPlugin { + private var mResult: Result? = null + private var handler: Handler? = null + private var activity: Activity? = null + private var threadPoolExecutor: ExecutorService? = null + private var logsChannel: EventChannel? = null + private var logger: EventChannel.EventSink? = null + + private var BODY_FAT_PERCENTAGE = "BODY_FAT_PERCENTAGE" + private var HEIGHT = "HEIGHT" + private var WEIGHT = "WEIGHT" + private var STEPS = "STEPS" + private var AGGREGATE_STEP_COUNT = "AGGREGATE_STEP_COUNT" + private var ACTIVE_ENERGY_BURNED = "ACTIVE_ENERGY_BURNED" + private var HEART_RATE = "HEART_RATE" + private var BODY_TEMPERATURE = "BODY_TEMPERATURE" + private var BLOOD_PRESSURE_SYSTOLIC = "BLOOD_PRESSURE_SYSTOLIC" + private var BLOOD_PRESSURE_DIASTOLIC = "BLOOD_PRESSURE_DIASTOLIC" + private var BLOOD_OXYGEN = "BLOOD_OXYGEN" + private var BLOOD_GLUCOSE = "BLOOD_GLUCOSE" + private var MOVE_MINUTES = "MOVE_MINUTES" + private var DISTANCE_DELTA = "DISTANCE_DELTA" + private var WATER = "WATER" + private var SLEEP = "SLEEP" + private var WORKOUT = "WORKOUT" + private var TOTAL_NUTRIENTS = "TOTAL_NUTRIENTS" + private var MENSTRUATION_DATA = "MENSTRUATION_DATA" + + private var iso8601DateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ") + + val workoutTypeMap = mapOf( + "AEROBICS" to FitnessActivities.AEROBICS, + "AMERICAN_FOOTBALL" to FitnessActivities.FOOTBALL_AMERICAN, + "ARCHERY" to FitnessActivities.ARCHERY, + "AUSTRALIAN_FOOTBALL" to FitnessActivities.FOOTBALL_AUSTRALIAN, + "BADMINTON" to FitnessActivities.BADMINTON, + "BASEBALL" to FitnessActivities.BASEBALL, + "BASKETBALL" to FitnessActivities.BASKETBALL, + "BIATHLON" to FitnessActivities.BIATHLON, + "BIKING" to FitnessActivities.BIKING, + "BOXING" to FitnessActivities.BOXING, + "CALISTHENICS" to FitnessActivities.CALISTHENICS, + "CIRCUIT_TRAINING" to FitnessActivities.CIRCUIT_TRAINING, + "CRICKET" to FitnessActivities.CRICKET, + "CROSS_COUNTRY_SKIING" to FitnessActivities.SKIING_CROSS_COUNTRY, + "CROSS_FIT" to FitnessActivities.CROSSFIT, + "CURLING" to FitnessActivities.CURLING, + "DANCING" to FitnessActivities.DANCING, + "DIVING" to FitnessActivities.DIVING, + "DOWNHILL_SKIING" to FitnessActivities.SKIING_DOWNHILL, + "ELEVATOR" to FitnessActivities.ELEVATOR, + "ELLIPTICAL" to FitnessActivities.ELLIPTICAL, + "ERGOMETER" to FitnessActivities.ERGOMETER, + "ESCALATOR" to FitnessActivities.ESCALATOR, + "FENCING" to FitnessActivities.FENCING, + "FRISBEE_DISC" to FitnessActivities.FRISBEE_DISC, + "GARDENING" to FitnessActivities.GARDENING, + "GOLF" to FitnessActivities.GOLF, + "GUIDED_BREATHING" to FitnessActivities.GUIDED_BREATHING, + "GYMNASTICS" to FitnessActivities.GYMNASTICS, + "HANDBALL" to FitnessActivities.HANDBALL, + "HIGH_INTENSITY_INTERVAL_TRAINING" to FitnessActivities.HIGH_INTENSITY_INTERVAL_TRAINING, + "HIKING" to FitnessActivities.HIKING, + "HOCKEY" to FitnessActivities.HOCKEY, + "HORSEBACK_RIDING" to FitnessActivities.HORSEBACK_RIDING, + "HOUSEWORK" to FitnessActivities.HOUSEWORK, + "IN_VEHICLE" to FitnessActivities.IN_VEHICLE, + "INTERVAL_TRAINING" to FitnessActivities.INTERVAL_TRAINING, + "JUMP_ROPE" to FitnessActivities.JUMP_ROPE, + "KAYAKING" to FitnessActivities.KAYAKING, + "KETTLEBELL_TRAINING" to FitnessActivities.KETTLEBELL_TRAINING, + "KICK_SCOOTER" to FitnessActivities.KICK_SCOOTER, + "KICKBOXING" to FitnessActivities.KICKBOXING, + "KITE_SURFING" to FitnessActivities.KITESURFING, + "MARTIAL_ARTS" to FitnessActivities.MARTIAL_ARTS, + "MEDITATION" to FitnessActivities.MEDITATION, + "MIXED_MARTIAL_ARTS" to FitnessActivities.MIXED_MARTIAL_ARTS, + "P90X" to FitnessActivities.P90X, + "PARAGLIDING" to FitnessActivities.PARAGLIDING, + "PILATES" to FitnessActivities.PILATES, + "POLO" to FitnessActivities.POLO, + "RACQUETBALL" to FitnessActivities.RACQUETBALL, + "ROCK_CLIMBING" to FitnessActivities.ROCK_CLIMBING, + "ROWING" to FitnessActivities.ROWING, + "RUGBY" to FitnessActivities.RUGBY, + "RUNNING_JOGGING" to FitnessActivities.RUNNING_JOGGING, + "RUNNING_SAND" to FitnessActivities.RUNNING_SAND, + "RUNNING_TREADMILL" to FitnessActivities.RUNNING_TREADMILL, + "RUNNING" to FitnessActivities.RUNNING, + "SAILING" to FitnessActivities.SAILING, + "SCUBA_DIVING" to FitnessActivities.SCUBA_DIVING, + "SKATING_CROSS" to FitnessActivities.SKATING_CROSS, + "SKATING_INDOOR" to FitnessActivities.SKATING_INDOOR, + "SKATING_INLINE" to FitnessActivities.SKATING_INLINE, + "SKATING" to FitnessActivities.SKATING, + "SKIING_BACK_COUNTRY" to FitnessActivities.SKIING_BACK_COUNTRY, + "SKIING_KITE" to FitnessActivities.SKIING_KITE, + "SKIING_ROLLER" to FitnessActivities.SKIING_ROLLER, + "SLEDDING" to FitnessActivities.SLEDDING, + "SNOWBOARDING" to FitnessActivities.SNOWBOARDING, + "SOCCER" to FitnessActivities.FOOTBALL_SOCCER, + "SOFTBALL" to FitnessActivities.SOFTBALL, + "SQUASH" to FitnessActivities.SQUASH, + "STAIR_CLIMBING_MACHINE" to FitnessActivities.STAIR_CLIMBING_MACHINE, + "STAIR_CLIMBING" to FitnessActivities.STAIR_CLIMBING, + "STANDUP_PADDLEBOARDING" to FitnessActivities.STANDUP_PADDLEBOARDING, + "STILL" to FitnessActivities.STILL, + "STRENGTH_TRAINING" to FitnessActivities.STRENGTH_TRAINING, + "SURFING" to FitnessActivities.SURFING, + "SWIMMING_OPEN_WATER" to FitnessActivities.SWIMMING_OPEN_WATER, + "SWIMMING_POOL" to FitnessActivities.SWIMMING_POOL, + "SWIMMING" to FitnessActivities.SWIMMING, + "TABLE_TENNIS" to FitnessActivities.TABLE_TENNIS, + "TEAM_SPORTS" to FitnessActivities.TEAM_SPORTS, + "TENNIS" to FitnessActivities.TENNIS, + "TILTING" to FitnessActivities.TILTING, + "VOLLEYBALL_BEACH" to FitnessActivities.VOLLEYBALL_BEACH, + "VOLLEYBALL_INDOOR" to FitnessActivities.VOLLEYBALL_INDOOR, + "VOLLEYBALL" to FitnessActivities.VOLLEYBALL, + "WAKEBOARDING" to FitnessActivities.WAKEBOARDING, + "WALKING_FITNESS" to FitnessActivities.WALKING_FITNESS, + "WALKING_NORDIC" to FitnessActivities.WALKING_NORDIC, + "WALKING_STROLLER" to FitnessActivities.WALKING_STROLLER, + "WALKING_TREADMILL" to FitnessActivities.WALKING_TREADMILL, + "WALKING" to FitnessActivities.WALKING, + "WATER_POLO" to FitnessActivities.WATER_POLO, + "WEIGHTLIFTING" to FitnessActivities.WEIGHTLIFTING, + "WHEELCHAIR" to FitnessActivities.WHEELCHAIR, + "WINDSURFING" to FitnessActivities.WINDSURFING, + "YOGA" to FitnessActivities.YOGA, + "ZUMBA" to FitnessActivities.ZUMBA, + "OTHER" to FitnessActivities.OTHER, + ) + + override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { + channel = MethodChannel(flutterPluginBinding.binaryMessenger, CHANNEL_NAME) + channel?.setMethodCallHandler(this) + threadPoolExecutor = Executors.newFixedThreadPool(4) + + logsChannel = EventChannel(flutterPluginBinding.binaryMessenger, LOGGER_CHANNEL_NAME) + logsChannel?.setStreamHandler(this) + } + + override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { + channel?.setMethodCallHandler(null) + channel = null + activity = null + + logsChannel?.setStreamHandler(null) + logsChannel = null + + threadPoolExecutor!!.shutdown() + threadPoolExecutor = null + } + + // This static function is optional and equivalent to onAttachedToEngine. It supports the old + // pre-Flutter-1.12 Android projects. You are encouraged to continue supporting + // plugin registration via this function while apps migrate to use the new Android APIs + // post-flutter-1.12 via https://flutter.dev/go/android-project-migration. + // + // It is encouraged to share logic between onAttachedToEngine and registerWith to keep + // them functionally equivalent. Only one of onAttachedToEngine or registerWith will be called + // depending on the user's project. onAttachedToEngine or registerWith must both be defined + // in the same class. + companion object { + @Suppress("unused") + @JvmStatic + fun registerWith(registrar: Registrar) { + val channel = MethodChannel(registrar.messenger(), CHANNEL_NAME) + val plugin = HealthPlugin(channel) + registrar.addActivityResultListener(plugin) + channel.setMethodCallHandler(plugin) } + } + + /// DataTypes to register + // private val fitnessOptions = FitnessOptions.builder() + // .addDataType(keyToHealthDataType(BODY_FAT_PERCENTAGE), FitnessOptions.ACCESS_READ) + // .addDataType(keyToHealthDataType(HEIGHT), FitnessOptions.ACCESS_READ) + // .addDataType(keyToHealthDataType(WEIGHT), FitnessOptions.ACCESS_READ) + // .addDataType(keyToHealthDataType(STEPS), FitnessOptions.ACCESS_READ) + // .addDataType(keyToHealthDataType(ACTIVE_ENERGY_BURNED), FitnessOptions.ACCESS_READ) + // .addDataType(keyToHealthDataType(HEART_RATE), FitnessOptions.ACCESS_READ) + // .addDataType(keyToHealthDataType(BODY_TEMPERATURE), FitnessOptions.ACCESS_READ) + // .addDataType(keyToHealthDataType(BLOOD_PRESSURE_SYSTOLIC), FitnessOptions.ACCESS_READ) + // .addDataType(keyToHealthDataType(BLOOD_OXYGEN), FitnessOptions.ACCESS_READ) + // .addDataType(keyToHealthDataType(BLOOD_GLUCOSE), FitnessOptions.ACCESS_READ) + // .addDataType(keyToHealthDataType(MOVE_MINUTES), FitnessOptions.ACCESS_READ) + // .addDataType(keyToHealthDataType(DISTANCE_DELTA), FitnessOptions.ACCESS_READ) + // .addDataType(keyToHealthDataType(WATER), FitnessOptions.ACCESS_READ) + // .addDataType(keyToHealthDataType(SLEEP_ASLEEP), FitnessOptions.ACCESS_READ) + // .accessActivitySessions(FitnessOptions.ACCESS_READ) + // .accessSleepSessions(FitnessOptions.ACCESS_READ) + // .build() + + + override fun success(p0: Any?) { + handler?.post { mResult?.success(p0) } + } + + override fun notImplemented() { + handler?.post { mResult?.notImplemented() } + } + + override fun error( + errorCode: String, errorMessage: String?, errorDetails: Any? + ) { + handler?.post { mResult?.error(errorCode, errorMessage, errorDetails) } + } + + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean { + if (requestCode == GOOGLE_FIT_PERMISSIONS_REQUEST_CODE) { + if (resultCode == Activity.RESULT_OK) { + Log.d("FLUTTER_HEALTH", "Access Granted!") + mResult?.success(true) + } else if (resultCode == Activity.RESULT_CANCELED) { + Log.d("FLUTTER_HEALTH", "Access Denied!") + mResult?.success(false) + } + } + return false + } + + private fun keyToHealthDataType(type: String): DataType { + return when (type) { + BODY_FAT_PERCENTAGE -> DataType.TYPE_BODY_FAT_PERCENTAGE + HEIGHT -> DataType.TYPE_HEIGHT + WEIGHT -> DataType.TYPE_WEIGHT + STEPS -> DataType.TYPE_STEP_COUNT_DELTA + AGGREGATE_STEP_COUNT -> DataType.AGGREGATE_STEP_COUNT_DELTA + ACTIVE_ENERGY_BURNED -> DataType.TYPE_CALORIES_EXPENDED + HEART_RATE -> DataType.TYPE_HEART_RATE_BPM + BODY_TEMPERATURE -> HealthDataTypes.TYPE_BODY_TEMPERATURE + BLOOD_PRESSURE_SYSTOLIC -> HealthDataTypes.TYPE_BLOOD_PRESSURE + BLOOD_PRESSURE_DIASTOLIC -> HealthDataTypes.TYPE_BLOOD_PRESSURE + BLOOD_OXYGEN -> HealthDataTypes.TYPE_OXYGEN_SATURATION + BLOOD_GLUCOSE -> HealthDataTypes.TYPE_BLOOD_GLUCOSE + MOVE_MINUTES -> DataType.TYPE_MOVE_MINUTES + DISTANCE_DELTA -> DataType.TYPE_DISTANCE_DELTA + WATER -> DataType.TYPE_HYDRATION + SLEEP -> DataType.TYPE_SLEEP_SEGMENT + WORKOUT -> DataType.TYPE_ACTIVITY_SEGMENT + TOTAL_NUTRIENTS -> DataType.TYPE_NUTRITION + MENSTRUATION_DATA -> HealthDataTypes.TYPE_MENSTRUATION + else -> throw IllegalArgumentException("Unsupported dataType: $type") + } + } + + private fun getField(type: String): Field { + return when (type) { + BODY_FAT_PERCENTAGE -> Field.FIELD_PERCENTAGE + HEIGHT -> Field.FIELD_HEIGHT + WEIGHT -> Field.FIELD_WEIGHT + STEPS -> Field.FIELD_STEPS + ACTIVE_ENERGY_BURNED -> Field.FIELD_CALORIES + HEART_RATE -> Field.FIELD_BPM + BODY_TEMPERATURE -> HealthFields.FIELD_BODY_TEMPERATURE + BLOOD_PRESSURE_SYSTOLIC -> HealthFields.FIELD_BLOOD_PRESSURE_SYSTOLIC + BLOOD_PRESSURE_DIASTOLIC -> HealthFields.FIELD_BLOOD_PRESSURE_DIASTOLIC + MENSTRUATION_DATA -> HealthFields.FIELD_MENSTRUAL_FLOW + BLOOD_OXYGEN -> HealthFields.FIELD_OXYGEN_SATURATION + BLOOD_GLUCOSE -> HealthFields.FIELD_BLOOD_GLUCOSE_LEVEL + MOVE_MINUTES -> Field.FIELD_DURATION + DISTANCE_DELTA -> Field.FIELD_DISTANCE + WATER -> Field.FIELD_VOLUME + SLEEP -> Field.FIELD_SLEEP_SEGMENT_TYPE + WORKOUT -> Field.FIELD_ACTIVITY + TOTAL_NUTRIENTS -> Field.FIELD_NUTRIENTS + else -> throw IllegalArgumentException("Unsupported dataType: $type") + } + } + + private fun isIntField(dataSource: DataSource, unit: Field): Boolean { + val dataPoint = DataPoint.builder(dataSource).build() + val value = dataPoint.getValue(unit) + return value.format == Field.FORMAT_INT32 + } + + /// Extracts the (numeric) value from a Health Data Point + private fun getHealthDataValue(dataPoint: DataPoint, field: Field): Any { + val value = dataPoint.getValue(field) + // Conversion is needed because glucose is stored as mmoll in Google Fit; + // while mgdl is used for glucose in this plugin. + val isGlucose = field == HealthFields.FIELD_BLOOD_GLUCOSE_LEVEL + return when (value.format) { + Field.FORMAT_FLOAT -> if (!isGlucose) value.asFloat() else value.asFloat() * MMOLL_2_MGDL + Field.FORMAT_INT32 -> value.asInt() + Field.FORMAT_STRING -> value.asString() + Field.FORMAT_MAP -> value.toString() + else -> Log.e("Unsupported format:", value.format.toString()) + } + } - override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { - channel?.setMethodCallHandler(null) - channel = null - activity = null - - logsChannel?.setStreamHandler(null) - logsChannel = null + private fun writeData(call: MethodCall, result: Result) { - threadPoolExecutor!!.shutdown() - threadPoolExecutor = null + if (activity == null) { + result.success(false) + return } - // This static function is optional and equivalent to onAttachedToEngine. It supports the old - // pre-Flutter-1.12 Android projects. You are encouraged to continue supporting - // plugin registration via this function while apps migrate to use the new Android APIs - // post-flutter-1.12 via https://flutter.dev/go/android-project-migration. - // - // It is encouraged to share logic between onAttachedToEngine and registerWith to keep - // them functionally equivalent. Only one of onAttachedToEngine or registerWith will be called - // depending on the user's project. onAttachedToEngine or registerWith must both be defined - // in the same class. - companion object { - @Suppress("unused") - @JvmStatic - fun registerWith(registrar: Registrar) { - val channel = MethodChannel(registrar.messenger(), CHANNEL_NAME) - val plugin = HealthPlugin(channel) - registrar.addActivityResultListener(plugin) - channel.setMethodCallHandler(plugin) - } + val type = call.argument("dataTypeKey")!! + val startTime = call.argument("startTimeSec")!! + val endTime = call.argument("endTimeSec")!! + val value = call.argument("value")!! + + // Look up data type and unit for the type key + val dataType = keyToHealthDataType(type) + val field = getField(type) + + val typesBuilder = FitnessOptions.builder() + typesBuilder.addDataType(dataType, FitnessOptions.ACCESS_WRITE) + + val dataSource = DataSource.Builder() + .setDataType(dataType) + .setType(DataSource.TYPE_RAW) + .setDevice(Device.getLocalDevice(activity!!.applicationContext)) + .setAppPackageName(activity!!.applicationContext) + .build() + + val builder = if (startTime == endTime) + DataPoint.builder(dataSource) + .setTimestamp(startTime, TimeUnit.SECONDS) + else + DataPoint.builder(dataSource) + .setTimeInterval(startTime, endTime, TimeUnit.SECONDS) + + // Conversion is needed because glucose is stored as mmoll in Google Fit; + // while mgdl is used for glucose in this plugin. + val isGlucose = field == HealthFields.FIELD_BLOOD_GLUCOSE_LEVEL + val dataPoint = if (!isIntField(dataSource, field)) + builder.setField(field, (if (!isGlucose) value else (value / MMOLL_2_MGDL).toFloat())) + .build() else + builder.setField(field, value.toInt()).build() + + val dataSet = DataSet.builder(dataSource) + .add(dataPoint) + .build() + + if (dataType == DataType.TYPE_SLEEP_SEGMENT) { + typesBuilder.accessSleepSessions(FitnessOptions.ACCESS_READ) } - - override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean { - if (requestCode == GOOGLE_FIT_PERMISSIONS_REQUEST_CODE) { - if (resultCode == Activity.RESULT_OK) { - Log.i("FLUTTER_HEALTH", "Access Granted!") - mResult?.success(true) - } else if (resultCode == Activity.RESULT_CANCELED) { - Log.i("FLUTTER_HEALTH", "Access Denied!") - mResult?.success(false) - } - return true - } - return false + val fitnessOptions = typesBuilder.build() + try { + val googleSignInAccount = + GoogleSignIn.getAccountForExtension(activity!!.applicationContext, fitnessOptions) + Fitness.getHistoryClient(activity!!.applicationContext, googleSignInAccount) + .insertData(dataSet) + .addOnSuccessListener { + Log.i("FLUTTER_HEALTH::SUCCESS", "DataSet added successfully!") + result.success(true) + } + .addOnFailureListener { e -> + Log.w("FLUTTER_HEALTH::ERROR", "There was an error adding the DataSet", e) + result.success(false) + } + } catch (e3: Exception) { + result.success(false) } + } - private fun keyToHealthDataType(type: String): DataType { - return when (type) { - BODY_FAT_PERCENTAGE -> DataType.TYPE_BODY_FAT_PERCENTAGE - HEIGHT -> DataType.TYPE_HEIGHT - WEIGHT -> DataType.TYPE_WEIGHT - STEPS -> DataType.TYPE_STEP_COUNT_DELTA - AGGREGATE_STEP_COUNT -> DataType.AGGREGATE_STEP_COUNT_DELTA - ACTIVE_ENERGY_BURNED -> DataType.TYPE_CALORIES_EXPENDED - HEART_RATE -> DataType.TYPE_HEART_RATE_BPM - BODY_TEMPERATURE -> HealthDataTypes.TYPE_BODY_TEMPERATURE - BLOOD_PRESSURE_SYSTOLIC -> HealthDataTypes.TYPE_BLOOD_PRESSURE - BLOOD_PRESSURE_DIASTOLIC -> HealthDataTypes.TYPE_BLOOD_PRESSURE - BLOOD_OXYGEN -> HealthDataTypes.TYPE_OXYGEN_SATURATION - BLOOD_GLUCOSE -> HealthDataTypes.TYPE_BLOOD_GLUCOSE - MOVE_MINUTES -> DataType.TYPE_MOVE_MINUTES - DISTANCE_DELTA -> DataType.TYPE_DISTANCE_DELTA - WATER -> DataType.TYPE_HYDRATION - SLEEP_ASLEEP -> DataType.TYPE_SLEEP_SEGMENT - SLEEP_AWAKE -> DataType.TYPE_SLEEP_SEGMENT - SLEEP_IN_BED -> DataType.TYPE_SLEEP_SEGMENT - WORKOUT -> DataType.TYPE_ACTIVITY_SEGMENT - TOTAL_NUTRIENTS -> DataType.TYPE_NUTRITION - MENSTRUATION_DATA -> HealthDataTypes.TYPE_MENSTRUATION - else -> throw IllegalArgumentException("Unsupported dataType: $type") - } + private fun writeWorkoutData(call: MethodCall, result: Result) { + if (activity == null) { + result.success(false) + return } - private fun getField(type: String): Field { - return when (type) { - BODY_FAT_PERCENTAGE -> Field.FIELD_PERCENTAGE - HEIGHT -> Field.FIELD_HEIGHT - WEIGHT -> Field.FIELD_WEIGHT - STEPS -> Field.FIELD_STEPS - ACTIVE_ENERGY_BURNED -> Field.FIELD_CALORIES - HEART_RATE -> Field.FIELD_BPM - BODY_TEMPERATURE -> HealthFields.FIELD_BODY_TEMPERATURE - BLOOD_PRESSURE_SYSTOLIC -> HealthFields.FIELD_BLOOD_PRESSURE_SYSTOLIC - BLOOD_PRESSURE_DIASTOLIC -> HealthFields.FIELD_BLOOD_PRESSURE_DIASTOLIC - BLOOD_OXYGEN -> HealthFields.FIELD_OXYGEN_SATURATION - BLOOD_GLUCOSE -> HealthFields.FIELD_BLOOD_GLUCOSE_LEVEL - MOVE_MINUTES -> Field.FIELD_DURATION - DISTANCE_DELTA -> Field.FIELD_DISTANCE - WATER -> Field.FIELD_VOLUME - SLEEP_ASLEEP -> Field.FIELD_SLEEP_SEGMENT_TYPE - SLEEP_AWAKE -> Field.FIELD_SLEEP_SEGMENT_TYPE - SLEEP_IN_BED -> Field.FIELD_SLEEP_SEGMENT_TYPE - WORKOUT -> Field.FIELD_ACTIVITY - MENSTRUATION_DATA -> HealthFields.FIELD_MENSTRUAL_FLOW - TOTAL_NUTRIENTS -> Field.FIELD_NUTRIENTS - else -> throw IllegalArgumentException("Unsupported dataType: $type") - } + val type = call.argument("activityType")!! + val startTime = call.argument("startTimeSec")!! + val endTime = call.argument("endTimeSec")!! + val totalEnergyBurned = call.argument("totalEnergyBurned") + val totalDistance = call.argument("totalDistance") + + val activityType = getActivityType(type) + + // Create the Activity Segment DataSource + val activitySegmentDataSource = DataSource.Builder() + .setAppPackageName(activity!!.packageName) + .setDataType(DataType.TYPE_ACTIVITY_SEGMENT) + .setStreamName("FLUTTER_HEALTH - Activity") + .setType(DataSource.TYPE_RAW) + .build() + // Create the Activity Segment + val activityDataPoint = DataPoint.builder(activitySegmentDataSource) + .setTimeInterval(startTime, endTime, TimeUnit.SECONDS) + .setActivityField(Field.FIELD_ACTIVITY, activityType) + .build() + // Add DataPoint to DataSet + val activitySegments = DataSet.builder(activitySegmentDataSource) + .add(activityDataPoint) + .build() + + // If distance is provided + var distanceDataSet: DataSet? = null + if (totalDistance != null) { + // Create a data source + val distanceDataSource = DataSource.Builder() + .setAppPackageName(activity!!.packageName) + .setDataType(DataType.TYPE_DISTANCE_DELTA) + .setStreamName("FLUTTER_HEALTH - Distance") + .setType(DataSource.TYPE_RAW) + .build() + + val distanceDataPoint = DataPoint.builder(distanceDataSource) + .setTimeInterval(startTime, endTime, TimeUnit.SECONDS) + .setField(Field.FIELD_DISTANCE, totalDistance.toFloat()) + .build() + // Create a data set + distanceDataSet = DataSet.builder(distanceDataSource) + .add(distanceDataPoint) + .build() } - - private fun isIntField(dataSource: DataSource, unit: Field): Boolean { - val dataPoint = DataPoint.builder(dataSource).build() - val value = dataPoint.getValue(unit) - return value.format == Field.FORMAT_INT32 + // If energyBurned is provided + var energyDataSet: DataSet? = null + if (totalEnergyBurned != null) { + // Create a data source + val energyDataSource = DataSource.Builder() + .setAppPackageName(activity!!.packageName) + .setDataType(DataType.TYPE_CALORIES_EXPENDED) + .setStreamName("FLUTTER_HEALTH - Calories") + .setType(DataSource.TYPE_RAW) + .build() + + val energyDataPoint = DataPoint.builder(energyDataSource) + .setTimeInterval(startTime, endTime, TimeUnit.SECONDS) + .setField(Field.FIELD_CALORIES, totalEnergyBurned.toFloat()) + .build() + // Create a data set + energyDataSet = DataSet.builder(energyDataSource) + .add(energyDataPoint) + .build() } - // / Extracts the (numeric) value from a Health Data Point - private fun getHealthDataValue(dataPoint: DataPoint, field: Field): Any { - val value = dataPoint.getValue(field) - // Conversion is needed because glucose is stored as mmoll in Google Fit; - // while mgdl is used for glucose in this plugin. - val isGlucose = field == HealthFields.FIELD_BLOOD_GLUCOSE_LEVEL - return when (value.format) { - Field.FORMAT_FLOAT -> if (!isGlucose) value.asFloat() else value.asFloat() * MMOLL_2_MGDL - Field.FORMAT_INT32 -> value.asInt() - Field.FORMAT_STRING -> value.asString() - Field.FORMAT_MAP -> value.asString() - else -> Log.e("Unsupported format:", value.format.toString()) - } + // Finish session setup + val session = Session.Builder() + .setName(activityType) // TODO: Make a sensible name / allow user to set name + .setDescription("") + .setIdentifier(UUID.randomUUID().toString()) + .setActivity(activityType) + .setStartTime(startTime, TimeUnit.SECONDS) + .setEndTime(endTime, TimeUnit.SECONDS) + .build() + // Build a session and add the values provided + val sessionInsertRequestBuilder = SessionInsertRequest.Builder() + .setSession(session) + .addDataSet(activitySegments) + if (totalDistance != null) { + sessionInsertRequestBuilder.addDataSet(distanceDataSet!!) } - - /** - * Delete records of the given type in the time range - */ - private fun delete(call: MethodCall, result: Result) { - if (useHealthConnectIfAvailable && healthConnectService.isHealthConnectAvailable(context)) { - mainScope.launch { - result.success(healthConnectService.deleteData(call)) - } - return - } - if (context == null) { - result.success(false) - return - } - - val type = call.argument("dataTypeKey")!! - val startTime = call.argument("startTimeSec")!! - val endTime = call.argument("endTimeSec")!! - - // Look up data type and unit for the type key - val dataType = keyToHealthDataType(type) - - val typesBuilder = FitnessOptions.builder() - typesBuilder.addDataType(dataType, FitnessOptions.ACCESS_WRITE) - - val dataSource = DataDeleteRequest.Builder() - .setTimeInterval(startTime, endTime, TimeUnit.MILLISECONDS) - .addDataType(dataType) - .deleteAllSessions() - .build() - - val fitnessOptions = typesBuilder.build() - - try { - val googleSignInAccount = - GoogleSignIn.getAccountForExtension(context!!.applicationContext, fitnessOptions) - Fitness.getHistoryClient(context!!.applicationContext, googleSignInAccount) - .deleteData(dataSource) - .addOnSuccessListener { - Log.i("FLUTTER_HEALTH::SUCCESS", "Dataset deleted successfully!") - result.success(true) - } - .addOnFailureListener(errHandler(result, "There was an error deleting the dataset")) - } catch (e3: Exception) { - result.success(false) - } + if (totalEnergyBurned != null) { + sessionInsertRequestBuilder.addDataSet(energyDataSet!!) } + val insertRequest = sessionInsertRequestBuilder.build() - /** - * Save a Blood Pressure measurement with systolic and diastolic values - */ - private fun writeBloodPressure(call: MethodCall, result: Result) { - if (useHealthConnectIfAvailable && healthConnectService.isHealthConnectAvailable(activity)) { - mainScope.launch { - result.success(healthConnectService.writeBloodPressure(call)) - } - return - } - if (context == null) { - result.success(false) - return - } - - val dataType = HealthDataTypes.TYPE_BLOOD_PRESSURE - val systolic = call.argument("systolic")!! - val diastolic = call.argument("diastolic")!! - val startTime = call.argument("startTimeSec")!! - val endTime = call.argument("endTimeSec")!! - - val typesBuilder = FitnessOptions.builder() - typesBuilder.addDataType(dataType, FitnessOptions.ACCESS_WRITE) - - val dataSource = DataSource.Builder() - .setDataType(dataType) - .setType(DataSource.TYPE_RAW) - .setDevice(Device.getLocalDevice(context!!.applicationContext)) - .setAppPackageName(context!!.applicationContext) - .build() - - val builder = DataPoint.builder(dataSource) - .setTimeInterval(startTime, endTime, TimeUnit.MILLISECONDS) - .setField(HealthFields.FIELD_BLOOD_PRESSURE_SYSTOLIC, systolic) - .setField(HealthFields.FIELD_BLOOD_PRESSURE_DIASTOLIC, diastolic) - .build() - - val dataPoint = builder - val dataSet = DataSet.builder(dataSource) - .add(dataPoint) - .build() - - val fitnessOptions = typesBuilder.build() - try { - val googleSignInAccount = - GoogleSignIn.getAccountForExtension(context!!.applicationContext, fitnessOptions) - Fitness.getHistoryClient(context!!.applicationContext, googleSignInAccount) - .insertData(dataSet) - .addOnSuccessListener { - Log.i("FLUTTER_HEALTH::SUCCESS", "Blood Pressure added successfully!") - result.success(true) - } - .addOnFailureListener( - errHandler( - result, - "There was an error adding the blood pressure data!", - ), - ) - } catch (e3: Exception) { - result.success(false) - } + val fitnessOptionsBuilder = FitnessOptions.builder() + .addDataType(DataType.TYPE_ACTIVITY_SEGMENT, FitnessOptions.ACCESS_WRITE) + if (totalDistance != null) { + fitnessOptionsBuilder.addDataType(DataType.TYPE_DISTANCE_DELTA, FitnessOptions.ACCESS_WRITE) } - - /** - * Save a data type in Google Fit - */ - private fun writeData(call: MethodCall, result: Result) { - if (useHealthConnectIfAvailable && healthConnectService.isHealthConnectAvailable(activity)) { - mainScope.launch { - result.success(healthConnectService.writeData(call)) - } - return - } - if (context == null) { - result.success(false) - return - } - - val type = call.argument("dataTypeKey")!! - val startTime = call.argument("startTimeSec")!! - val endTime = call.argument("endTimeSec")!! - val value = call.argument("value")!! - - // Look up data type and unit for the type key - val dataType = keyToHealthDataType(type) - val field = getField(type) - - val typesBuilder = FitnessOptions.builder() - typesBuilder.addDataType(dataType, FitnessOptions.ACCESS_WRITE) - - val dataSource = DataSource.Builder() - .setDataType(dataType) - .setType(DataSource.TYPE_RAW) - .setDevice(Device.getLocalDevice(context!!.applicationContext)) - .setAppPackageName(context!!.applicationContext) - .build() - - val builder = if (startTime == endTime) { - DataPoint.builder(dataSource) - .setTimestamp(startTime, TimeUnit.SECONDS) - } else { - DataPoint.builder(dataSource) - .setTimeInterval(startTime, endTime, TimeUnit.SECONDS) - } - - // Conversion is needed because glucose is stored as mmoll in Google Fit; - // while mgdl is used for glucose in this plugin. - val isGlucose = field == HealthFields.FIELD_BLOOD_GLUCOSE_LEVEL - val dataPoint = if (!isIntField(dataSource, field)) { - builder.setField(field, (if (!isGlucose) value else (value / MMOLL_2_MGDL).toFloat())) - .build() - } else { - builder.setField(field, value.toInt()).build() - } - - val dataSet = DataSet.builder(dataSource) - .add(dataPoint) - .build() - - if (dataType == DataType.TYPE_SLEEP_SEGMENT) { - typesBuilder.accessSleepSessions(FitnessOptions.ACCESS_READ) - } - val fitnessOptions = typesBuilder.build() - try { - val googleSignInAccount = - GoogleSignIn.getAccountForExtension(context!!.applicationContext, fitnessOptions) - Fitness.getHistoryClient(context!!.applicationContext, googleSignInAccount) - .insertData(dataSet) - .addOnSuccessListener { - Log.i("FLUTTER_HEALTH::SUCCESS", "Dataset added successfully!") - result.success(true) - } - .addOnFailureListener(errHandler(result, "There was an error adding the dataset")) - } catch (e3: Exception) { - result.success(false) - } + if (totalEnergyBurned != null) { + fitnessOptionsBuilder.addDataType(DataType.TYPE_CALORIES_EXPENDED, FitnessOptions.ACCESS_WRITE) } + val fitnessOptions = fitnessOptionsBuilder.build() + + try { + val googleSignInAccount = + GoogleSignIn.getAccountForExtension(activity!!.applicationContext, fitnessOptions) + if (!GoogleSignIn.hasPermissions(googleSignInAccount, fitnessOptions)) { + GoogleSignIn.requestPermissions( + activity!!, + GOOGLE_FIT_PERMISSIONS_REQUEST_CODE, + googleSignInAccount, + fitnessOptions + ) + } + Fitness.getSessionsClient( + activity!!.applicationContext, + GoogleSignIn.getAccountForExtension(activity!!.applicationContext, fitnessOptions) + ) + .insertSession(insertRequest) + .addOnSuccessListener { + Log.i("FLUTTER_HEALTH::SUCCESS", "Workout was successfully added!") + result.success(true) + } + .addOnFailureListener { e -> + Log.w("FLUTTER_HEALTH::ERROR", "There was a problem adding the workout: ", e) + result.success(false) + } + } catch (e: Exception) { + result.success(false) + } + } - /** - * Save the blood oxygen saturation, in Google Fit with the supplemental flow rate, in HealthConnect without - */ - private fun writeBloodOxygen(call: MethodCall, result: Result) { - // Health Connect does not support supplemental flow rate, thus it is ignored - if (useHealthConnectIfAvailable && healthConnectService.isHealthConnectAvailable(activity)) { - mainScope.launch { - result.success(healthConnectService.writeData(call)) - } - return - } - - if (context == null) { - result.success(false) - return - } - val dataType = HealthDataTypes.TYPE_OXYGEN_SATURATION - val startTime = call.argument("startTimeSec")!! - val endTime = call.argument("endTimeSec")!! - val saturation = call.argument("value")!! - val flowRate = call.argument("flowRate")!! - - val typesBuilder = FitnessOptions.builder() - typesBuilder.addDataType(dataType, FitnessOptions.ACCESS_WRITE) - - val dataSource = DataSource.Builder() - .setDataType(dataType) - .setType(DataSource.TYPE_RAW) - .setDevice(Device.getLocalDevice(context!!.applicationContext)) - .setAppPackageName(context!!.applicationContext) - .build() - - val builder = if (startTime == endTime) { - DataPoint.builder(dataSource) - .setTimestamp(startTime, TimeUnit.MILLISECONDS) - } else { - DataPoint.builder(dataSource) - .setTimeInterval(startTime, endTime, TimeUnit.MILLISECONDS) - } - - builder.setField(HealthFields.FIELD_SUPPLEMENTAL_OXYGEN_FLOW_RATE, flowRate) - builder.setField(HealthFields.FIELD_OXYGEN_SATURATION, saturation) - - val dataPoint = builder.build() - val dataSet = DataSet.builder(dataSource) - .add(dataPoint) - .build() - - val fitnessOptions = typesBuilder.build() - try { - val googleSignInAccount = - GoogleSignIn.getAccountForExtension(context!!.applicationContext, fitnessOptions) - Fitness.getHistoryClient(context!!.applicationContext, googleSignInAccount) - .insertData(dataSet) - .addOnSuccessListener { - Log.i("FLUTTER_HEALTH::SUCCESS", "Blood Oxygen added successfully!") - result.success(true) - } - .addOnFailureListener( - errHandler( - result, - "There was an error adding the blood oxygen data!", - ), - ) - } catch (e3: Exception) { - result.success(false) - } + private fun getData(call: MethodCall, result: Result) { + if (activity == null) { + result.success(null) + return } - /** - * Save a Workout session with options for distance and calories expended - */ - private fun writeWorkoutData(call: MethodCall, result: Result) { - if (useHealthConnectIfAvailable && healthConnectService.isHealthConnectAvailable(activity)) { - mainScope.launch { - result.success(healthConnectService.writeWorkoutData(call)) - } - return - } - if (context == null) { - result.success(false) - return - } - - val type = call.argument("activityType")!! - val startTime = call.argument("startTimeSec")!! - val endTime = call.argument("endTimeSec")!! - val totalEnergyBurned = call.argument("totalEnergyBurned") - val totalDistance = call.argument("totalDistance") - - val activityType = getActivityType(type) - // Create the Activity Segment DataSource - val activitySegmentDataSource = DataSource.Builder() - .setAppPackageName(context!!.packageName) - .setDataType(DataType.TYPE_ACTIVITY_SEGMENT) - .setStreamName("FLUTTER_HEALTH - Activity") - .setType(DataSource.TYPE_RAW) - .build() - // Create the Activity Segment - val activityDataPoint = DataPoint.builder(activitySegmentDataSource) + val type = call.argument("dataTypeKey")!! + val startTime = call.argument("startTimeSec")!! + val endTime = call.argument("endTimeSec")!! + // Look up data type and unit for the type key + val dataType = keyToHealthDataType(type) + val field = getField(type) + val typesBuilder = FitnessOptions.builder() + typesBuilder.addDataType(dataType) + + // Add special cases for accessing workouts or sleep data. + if (dataType == DataType.TYPE_SLEEP_SEGMENT) { + typesBuilder.accessSleepSessions(FitnessOptions.ACCESS_READ) + } else if (dataType == DataType.TYPE_ACTIVITY_SEGMENT) { + typesBuilder.accessActivitySessions(FitnessOptions.ACCESS_READ) + .addDataType(DataType.TYPE_CALORIES_EXPENDED, FitnessOptions.ACCESS_READ) + .addDataType(DataType.TYPE_DISTANCE_DELTA, FitnessOptions.ACCESS_READ) + } + val fitnessOptions = typesBuilder.build() + + val googleSignInAccount = + GoogleSignIn.getAccountForExtension(activity!!.applicationContext, fitnessOptions) + // Handle data types + when (dataType) { + DataType.TYPE_SLEEP_SEGMENT -> { + // request to the sessions for sleep data + val request = SessionReadRequest.Builder() + .setTimeInterval(startTime, endTime, TimeUnit.SECONDS) + .enableServerQueries() + .readSessionsFromAllApps() + .includeSleepSessions() + .build() + Fitness.getSessionsClient(activity!!.applicationContext, googleSignInAccount) + .readSession(request) + .addOnSuccessListener(threadPoolExecutor!!, sleepDataHandler(type, result)) + .addOnFailureListener(errHandler(result)) + } + DataType.TYPE_NUTRITION -> { + Fitness.getHistoryClient(activity!!.applicationContext, googleSignInAccount) + .readData( + DataReadRequest.Builder() + .read(dataType) + .setTimeRange(startTime, endTime, TimeUnit.SECONDS) + .build() + ) + .addOnSuccessListener(threadPoolExecutor!!, nutritionDataHandler(dataType, result)) + .addOnFailureListener(errHandler(result)) + } + DataType.TYPE_ACTIVITY_SEGMENT -> { + val readRequest: SessionReadRequest + val readRequestBuilder = SessionReadRequest.Builder() .setTimeInterval(startTime, endTime, TimeUnit.SECONDS) - .setActivityField(Field.FIELD_ACTIVITY, activityType) - .build() - // Add DataPoint to DataSet - val activitySegments = DataSet.builder(activitySegmentDataSource) - .add(activityDataPoint) - .build() - - // If distance is provided - var distanceDataSet: DataSet? = null - if (totalDistance != null) { - // Create a data source - val distanceDataSource = DataSource.Builder() - .setAppPackageName(context!!.packageName) - .setDataType(DataType.TYPE_DISTANCE_DELTA) - .setStreamName("FLUTTER_HEALTH - Distance") - .setType(DataSource.TYPE_RAW) - .build() - - val distanceDataPoint = DataPoint.builder(distanceDataSource) - .setTimeInterval(startTime, endTime, TimeUnit.SECONDS) - .setField(Field.FIELD_DISTANCE, totalDistance.toFloat()) - .build() - // Create a data set - distanceDataSet = DataSet.builder(distanceDataSource) - .add(distanceDataPoint) - .build() - } - // If energyBurned is provided - var energyDataSet: DataSet? = null - if (totalEnergyBurned != null) { - // Create a data source - val energyDataSource = DataSource.Builder() - .setAppPackageName(context!!.packageName) - .setDataType(DataType.TYPE_CALORIES_EXPENDED) - .setStreamName("FLUTTER_HEALTH - Calories") - .setType(DataSource.TYPE_RAW) - .build() - - val energyDataPoint = DataPoint.builder(energyDataSource) - .setTimeInterval(startTime, endTime, TimeUnit.SECONDS) - .setField(Field.FIELD_CALORIES, totalEnergyBurned.toFloat()) - .build() - // Create a data set - energyDataSet = DataSet.builder(energyDataSource) - .add(energyDataPoint) - .build() - } - - // Finish session setup - val session = Session.Builder() - .setName(activityType) // TODO: Make a sensible name / allow user to set name - .setDescription("") - .setIdentifier(UUID.randomUUID().toString()) - .setActivity(activityType) - .setStartTime(startTime, TimeUnit.SECONDS) - .setEndTime(endTime, TimeUnit.SECONDS) - .build() - // Build a session and add the values provided - val sessionInsertRequestBuilder = SessionInsertRequest.Builder() - .setSession(session) - .addDataSet(activitySegments) - if (totalDistance != null) { - sessionInsertRequestBuilder.addDataSet(distanceDataSet!!) - } - if (totalEnergyBurned != null) { - sessionInsertRequestBuilder.addDataSet(energyDataSet!!) - } - val insertRequest = sessionInsertRequestBuilder.build() - - val fitnessOptionsBuilder = FitnessOptions.builder() - .addDataType(DataType.TYPE_ACTIVITY_SEGMENT, FitnessOptions.ACCESS_WRITE) - if (totalDistance != null) { - fitnessOptionsBuilder.addDataType( - DataType.TYPE_DISTANCE_DELTA, - FitnessOptions.ACCESS_WRITE, - ) - } - if (totalEnergyBurned != null) { - fitnessOptionsBuilder.addDataType( - DataType.TYPE_CALORIES_EXPENDED, - FitnessOptions.ACCESS_WRITE, - ) - } - val fitnessOptions = fitnessOptionsBuilder.build() - - try { - val googleSignInAccount = - GoogleSignIn.getAccountForExtension(context!!.applicationContext, fitnessOptions) - Fitness.getSessionsClient( - context!!.applicationContext, - googleSignInAccount, - ) - .insertSession(insertRequest) - .addOnSuccessListener { - Log.i("FLUTTER_HEALTH::SUCCESS", "Workout was successfully added!") - result.success(true) - } - .addOnFailureListener(errHandler(result, "There was an error adding the workout")) - } catch (e: Exception) { - result.success(false) - } + .enableServerQueries() + .readSessionsFromAllApps() + .includeActivitySessions() + .read(dataType) + .read(DataType.TYPE_CALORIES_EXPENDED) + + // If fine location is enabled, read distance data + if (ContextCompat.checkSelfPermission( + activity!!.applicationContext, + android.Manifest.permission.ACCESS_FINE_LOCATION + ) == PackageManager.PERMISSION_GRANTED + ) { + readRequestBuilder.read(DataType.TYPE_DISTANCE_DELTA) + } + readRequest = readRequestBuilder.build() + Fitness.getSessionsClient(activity!!.applicationContext, googleSignInAccount) + .readSession(readRequest) + .addOnSuccessListener(threadPoolExecutor!!, workoutDataHandler(type, result)) + .addOnFailureListener(errHandler(result)) + } + else -> { + Fitness.getHistoryClient(activity!!.applicationContext, googleSignInAccount) + .readData( + DataReadRequest.Builder() + .read(dataType) + .setTimeRange(startTime, endTime, TimeUnit.SECONDS) + .build() + ) + .addOnSuccessListener(threadPoolExecutor!!, dataHandler(dataType, field, result)) + .addOnFailureListener(errHandler(result)) + } } - /** - * Get all datapoints of the DataType within the given time range - */ - private fun getData(call: MethodCall, result: Result) { - if (useHealthConnectIfAvailable && healthConnectService.isHealthConnectAvailable(activity)) { - mainScope.launch { - val data = healthConnectService.getData(call) - result.success(data) - } - return - } - - if (context == null) { - result.success(null) - return - } - - val type = call.argument("dataTypeKey")!! - val startTime = call.argument("startTimeSec")!! - val endTime = call.argument("endTimeSec")!! - // Look up data type and unit for the type key - val dataType = keyToHealthDataType(type) - val field = getField(type) - val typesBuilder = FitnessOptions.builder() - typesBuilder.addDataType(dataType) - - // Add special cases for accessing workouts or sleep data. - if (dataType == DataType.TYPE_SLEEP_SEGMENT) { - typesBuilder.accessSleepSessions(FitnessOptions.ACCESS_READ) - } else if (dataType == DataType.TYPE_ACTIVITY_SEGMENT) { - typesBuilder.accessActivitySessions(FitnessOptions.ACCESS_READ) - .addDataType(DataType.TYPE_CALORIES_EXPENDED, FitnessOptions.ACCESS_READ) - .addDataType(DataType.TYPE_DISTANCE_DELTA, FitnessOptions.ACCESS_READ) - } - val fitnessOptions = typesBuilder.build() - val googleSignInAccount = - GoogleSignIn.getAccountForExtension(context!!.applicationContext, fitnessOptions) - // Handle data types - when (dataType) { - DataType.TYPE_SLEEP_SEGMENT -> { - // request to the sessions for sleep data - val request = SessionReadRequest.Builder() - .setTimeInterval(startTime, endTime, TimeUnit.SECONDS) - .enableServerQueries() - .readSessionsFromAllApps() - .includeSleepSessions() - .build() - Fitness.getSessionsClient(context!!.applicationContext, googleSignInAccount) - .readSession(request) - .addOnSuccessListener(threadPoolExecutor!!, sleepDataHandler(type, result)) - .addOnFailureListener( - errHandler( - result, - "There was an error getting the sleeping data!", - ), - ) - } - - DataType.TYPE_NUTRITION -> { - Fitness.getHistoryClient(activity!!.applicationContext, googleSignInAccount) - .readData( - DataReadRequest.Builder() - .read(dataType) - .setTimeRange(startTime, endTime, TimeUnit.SECONDS) - .build() - ) - .addOnSuccessListener(threadPoolExecutor!!, nutritionDataHandler(dataType, result)) - .addOnFailureListener(errHandler(result, "There was an error getting the nutrition data!")) - } - - DataType.TYPE_ACTIVITY_SEGMENT -> { - val readRequest: SessionReadRequest - val readRequestBuilder = SessionReadRequest.Builder() - .setTimeInterval(startTime, endTime, TimeUnit.SECONDS) - .enableServerQueries() - .readSessionsFromAllApps() - .includeActivitySessions() - .read(dataType) - .read(DataType.TYPE_CALORIES_EXPENDED) - - // If fine location is enabled, read distance data - if (ContextCompat.checkSelfPermission( - context!!.applicationContext, - android.Manifest.permission.ACCESS_FINE_LOCATION, - ) == PackageManager.PERMISSION_GRANTED - ) { - readRequestBuilder.read(DataType.TYPE_DISTANCE_DELTA) - } - readRequest = readRequestBuilder.build() - Fitness.getSessionsClient(context!!.applicationContext, googleSignInAccount) - .readSession(readRequest) - .addOnSuccessListener(threadPoolExecutor!!, workoutDataHandler(result)) - .addOnFailureListener( - errHandler( - result, - "There was an error getting the workout data!", - ), - ) - } - - else -> { - Fitness.getHistoryClient(context!!.applicationContext, googleSignInAccount) - .readData( - DataReadRequest.Builder() - .read(dataType) - .setTimeRange(startTime, endTime, TimeUnit.SECONDS) - .build(), - ) - .addOnSuccessListener( - threadPoolExecutor!!, - dataHandler(dataType, field, result), - ) - .addOnFailureListener( - errHandler( - result, - "There was an error getting the data!", - ), - ) - } - } + } + + private fun nutritionDataHandler(dataType: DataType, result: Result) = + OnSuccessListener { response: DataReadResponse -> + /// Fetch all data points for the specified DataType + val dataSet = response.getDataSet(dataType) + /// For each data point, extract the contents and send them to Flutter, along with date and unit. + val healthData = dataSet.dataPoints.mapIndexed { _, dataPoint -> + var startTime = Date(dataPoint.getStartTime(TimeUnit.MILLISECONDS)) + var endTime = Date(dataPoint.getEndTime(TimeUnit.MILLISECONDS)) + + return@mapIndexed hashMapOf( + "nutrients" to getHealthDataValue(dataPoint, Field.FIELD_NUTRIENTS), + "foodItem" to getHealthDataValue(dataPoint, Field.FIELD_FOOD_ITEM), + "mealType" to getHealthDataValue(dataPoint, Field.FIELD_MEAL_TYPE), + "startTime" to iso8601DateFormat.format(startTime), + "endTime" to iso8601DateFormat.format(endTime), + "deviceModel" to (dataPoint.originalDataSource.appPackageName + ?: (dataPoint.originalDataSource.device?.model + ?: "")), + "sourceId" to dataPoint.originalDataSource.streamIdentifier + ) + } + activity!!.runOnUiThread { result.success(healthData) } } - private fun nutritionDataHandler(dataType: DataType, result: Result) = - OnSuccessListener { response: DataReadResponse -> - /// Fetch all data points for the specified DataType - val dataSet = response.getDataSet(dataType) - /// For each data point, extract the contents and send them to Flutter, along with date and unit. - val healthData = dataSet.dataPoints.mapIndexed { _, dataPoint -> - val startTime = Date(dataPoint.getStartTime(TimeUnit.SECONDS)) - val endTime = Date(dataPoint.getEndTime(TimeUnit.SECONDS)) - - return@mapIndexed hashMapOf( - "nutrients" to getHealthDataValue(dataPoint, Field.FIELD_NUTRIENTS), - "foodItem" to getHealthDataValue(dataPoint, Field.FIELD_FOOD_ITEM), - "mealType" to getHealthDataValue(dataPoint, Field.FIELD_MEAL_TYPE), - "startTimeSec" to iso8601DateFormat.format(startTime), - "endTimeSec" to iso8601DateFormat.format(endTime), - "deviceModel" to (dataPoint.originalDataSource.appPackageName - ?: (dataPoint.originalDataSource.device?.model - ?: "")), - "sourceId" to dataPoint.originalDataSource.streamIdentifier - ) - } - activity!!.runOnUiThread { result.success(healthData) } - } - - private fun dataHandler(dataType: DataType, field: Field, result: Result) = - OnSuccessListener { response: DataReadResponse -> - // / Fetch all data points for the specified DataType - val dataSet = response.getDataSet(dataType) - // / For each data point, extract the contents and send them to Flutter, along with date and unit. - val healthData = dataSet.dataPoints.mapIndexed { _, dataPoint -> - - val startTime = Date(dataPoint.getStartTime(TimeUnit.MILLISECONDS)) - val endTime = Date(dataPoint.getEndTime(TimeUnit.MILLISECONDS)) - - return@mapIndexed hashMapOf( - "value" to getHealthDataValue(dataPoint, field), - "startTimeSec" to iso8601DateFormat.format(startTime), - "endTimeSec" to iso8601DateFormat.format(endTime), - "deviceModel" to (dataPoint.originalDataSource.appPackageName - ?: (dataPoint.originalDataSource.device?.model - ?: "")), - "sourceId" to dataPoint.originalDataSource.streamIdentifier - ) - } - Handler(context!!.mainLooper).run { result.success(healthData) } - } - - private fun errHandler(result: Result, addMessage: String) = OnFailureListener { exception -> - Handler(context!!.mainLooper).run { result.success(null) } - Log.w("FLUTTER_HEALTH::ERROR", addMessage) - Log.w("FLUTTER_HEALTH::ERROR", exception.message ?: "unknown error") - Log.w("FLUTTER_HEALTH::ERROR", exception.stackTrace.toString()) + private fun dataHandler(dataType: DataType, field: Field, result: Result) = + OnSuccessListener { response: DataReadResponse -> + /// Fetch all data points for the specified DataType + val dataSet = response.getDataSet(dataType) + /// For each data point, extract the contents and send them to Flutter, along with date and unit. + val healthData = dataSet.dataPoints.mapIndexed { _, dataPoint -> + + val startTime = Date(dataPoint.getStartTime(TimeUnit.MILLISECONDS)) + val endTime = Date(dataPoint.getEndTime(TimeUnit.MILLISECONDS)) + + return@mapIndexed hashMapOf( + "value" to getHealthDataValue(dataPoint, field), + "startTime" to iso8601DateFormat.format(startTime), + "endTime" to iso8601DateFormat.format(endTime), + "deviceModel" to (dataPoint.originalDataSource.appPackageName + ?: (dataPoint.originalDataSource.device?.model + ?: "")), + "sourceId" to dataPoint.originalDataSource.streamIdentifier + ) + } + activity!!.runOnUiThread { result.success(healthData) } } - private fun sleepDataHandler(type: String, result: Result) = - OnSuccessListener { response: SessionReadResponse -> - val healthData: MutableList> = mutableListOf() - for (session in response.sessions) { - // Return sleep time in Minutes if requested ASLEEP data - if (isAnySleepType(type)) { - - val startTime = Date(session.getStartTime(TimeUnit.MILLISECONDS)) - val endTime = Date(session.getEndTime(TimeUnit.MILLISECONDS)) - - healthData.add( - hashMapOf( - // Total duration of sleep in minutes - "startTimeSec" to iso8601DateFormat.format(startTime), - "endTimeSec" to iso8601DateFormat.format(endTime), - "appPackageName" to session.appPackageName, - "identifier" to session.identifier, - // If the sleep session has finer granularity sub-components, extract them: - "dataSet" to response.getDataSet(session).map { dataSet -> - dataSet.dataPoints.map { point -> - // Sleep stage stored as integer, in order. - val sleepStageVal = point.getValue(Field.FIELD_SLEEP_SEGMENT_TYPE).asInt() - val segmentStart = Date(point.getStartTime(TimeUnit.MILLISECONDS)) - val segmentEnd = Date(point.getEndTime(TimeUnit.MILLISECONDS)) - hashMapOf( - // Sleep stage stored as integer, in order. - "value" to sleepStageVal, - "startTimeSec" to iso8601DateFormat.format(segmentStart), - "endTimeSec" to iso8601DateFormat.format(segmentEnd), - ) - } - } - ) - ) - } - } - Handler(context!!.mainLooper).run { result.success(healthData) } - } - - private fun isAnySleepType(type: String): Boolean = - type == SLEEP_ASLEEP - || type == SLEEP_AWAKE - || type == SLEEP_IN_BED - || type == SLEEP_DEEP - || type == SLEEP_LIGHT - || type == SLEEP_REM - - private fun workoutDataHandler(result: Result) = - OnSuccessListener { response: SessionReadResponse -> - val healthData: MutableList> = mutableListOf() - for (session in response.sessions) { - // Look for calories and distance if they - var totalEnergyBurned = 0.0 - var totalDistance = 0.0 - for (dataSet in response.getDataSet(session)) { - if (dataSet.dataType == DataType.TYPE_CALORIES_EXPENDED) { - for (dataPoint in dataSet.dataPoints) { - totalEnergyBurned += dataPoint.getValue(Field.FIELD_CALORIES).toString() - .toDouble() - } - } - if (dataSet.dataType == DataType.TYPE_DISTANCE_DELTA) { - for (dataPoint in dataSet.dataPoints) { - totalDistance += dataPoint.getValue(Field.FIELD_DISTANCE).toString() - .toDouble() - } - } - } - - val startTime = Date(session.getStartTime(TimeUnit.MILLISECONDS)) - val endTime = Date(session.getEndTime(TimeUnit.MILLISECONDS)) - - healthData.add( - hashMapOf( - "workoutActivityType" to workoutTypeMap.filterValues { it == session.activity }.keys.first(), - "activity" to session.activity, - "description" to session.description, - "totalEnergyBurned" to if (totalEnergyBurned == 0.0) null else totalEnergyBurned, - "totalDistance" to if (totalDistance == 0.0) null else totalDistance, - "startTimeSec" to iso8601DateFormat.format(startTime), - "endTimeSec" to iso8601DateFormat.format(endTime), - "sessionAppPackageName" to session.appPackageName, - "sessionIdentifier" to session.identifier - ), - ) - } - Handler(context!!.mainLooper).run { result.success(healthData) } - } - - private fun callToHealthTypes(call: MethodCall): FitnessOptions { - val typesBuilder = FitnessOptions.builder() - val args = call.arguments as HashMap<*, *> - val types = (args["types"] as? ArrayList<*>)?.filterIsInstance() - val permissions = (args["permissions"] as? ArrayList<*>)?.filterIsInstance() - - assert(types != null) - assert(permissions != null) - assert(types!!.count() == permissions!!.count()) - - for ((i, typeKey) in types.withIndex()) { - val access = permissions[i] - val dataType = keyToHealthDataType(typeKey) - when (access) { - 0 -> typesBuilder.addDataType(dataType, FitnessOptions.ACCESS_READ) - 1 -> typesBuilder.addDataType(dataType, FitnessOptions.ACCESS_WRITE) - 2 -> { - typesBuilder.addDataType(dataType, FitnessOptions.ACCESS_READ) - typesBuilder.addDataType(dataType, FitnessOptions.ACCESS_WRITE) - } - - else -> throw IllegalArgumentException("Unknown access type $access") - } - if (typeKey == SLEEP_ASLEEP || typeKey == SLEEP_AWAKE || typeKey == SLEEP_IN_BED) { - typesBuilder.accessSleepSessions(FitnessOptions.ACCESS_READ) - when (access) { - 0 -> typesBuilder.accessSleepSessions(FitnessOptions.ACCESS_READ) - 1 -> typesBuilder.accessSleepSessions(FitnessOptions.ACCESS_WRITE) - 2 -> { - typesBuilder.accessSleepSessions(FitnessOptions.ACCESS_READ) - typesBuilder.accessSleepSessions(FitnessOptions.ACCESS_WRITE) - } - - else -> throw IllegalArgumentException("Unknown access type $access") - } - } - if (typeKey == WORKOUT) { - when (access) { - 0 -> typesBuilder.accessActivitySessions(FitnessOptions.ACCESS_READ) - 1 -> typesBuilder.accessActivitySessions(FitnessOptions.ACCESS_WRITE) - 2 -> { - typesBuilder.accessActivitySessions(FitnessOptions.ACCESS_READ) - typesBuilder.accessActivitySessions(FitnessOptions.ACCESS_WRITE) - } - - else -> throw IllegalArgumentException("Unknown access type $access") + private fun errHandler(result: Result) = OnFailureListener { exception -> + activity!!.runOnUiThread { result.success(null) } + Log.i("FLUTTER_HEALTH::ERROR", exception.message ?: "unknown error") + Log.i("FLUTTER_HEALTH::ERROR", exception.stackTrace.toString()) + } + + private fun sleepDataHandler(type: String, result: Result) = + OnSuccessListener { response: SessionReadResponse -> + + val healthData: MutableList> = mutableListOf() + for (session in response.sessions) { + if (type == SLEEP) { + val startTime = Date(session.getStartTime(TimeUnit.MILLISECONDS)) + val endTime = Date(session.getEndTime(TimeUnit.MILLISECONDS)) + + healthData.add( + hashMapOf( + // Total duration of sleep in minutes + "startTime" to iso8601DateFormat.format(startTime), + "endTime" to iso8601DateFormat.format(endTime), + "appPackageName" to session.appPackageName, + "identifier" to session.identifier, + // If the sleep session has finer granularity sub-components, extract them: + "dataSet" to response.getDataSet(session).map { dataSet -> + dataSet.dataPoints.map { point -> + // Sleep stage stored as integer, in order. + val sleepStageVal = point.getValue(Field.FIELD_SLEEP_SEGMENT_TYPE).asInt() + val segmentStart = Date(point.getStartTime(TimeUnit.MILLISECONDS)) + val segmentEnd = Date(point.getEndTime(TimeUnit.MILLISECONDS)) + hashMapOf( + // Sleep stage stored as integer, in order. + "value" to sleepStageVal, + "startTime" to iso8601DateFormat.format(segmentStart), + "endTime" to iso8601DateFormat.format(segmentEnd), + ) } - } + } + ) + ) } - return typesBuilder.build() + } + activity!!.runOnUiThread { result.success(healthData) } } - private fun hasPermissions(call: MethodCall, result: Result) { - if (useHealthConnectIfAvailable && healthConnectService.isHealthConnectAvailable(activity)) { - mainScope.launch { - val hasPermissions = healthConnectService.hasPermissions(call) - result.success(hasPermissions) + private fun workoutDataHandler(type: String, result: Result) = + OnSuccessListener { response: SessionReadResponse -> + val healthData: MutableList> = mutableListOf() + for (session in response.sessions) { + // Look for calories and distance if they + var totalEnergyBurned = 0.0 + var totalDistance = 0.0 + for (dataSet in response.getDataSet(session)) { + if (dataSet.dataType == DataType.TYPE_CALORIES_EXPENDED) { + for (dataPoint in dataSet.dataPoints) { + totalEnergyBurned += dataPoint.getValue(Field.FIELD_CALORIES).toString().toDouble() } - return - } - if (context == null) { - result.success(false) - return - } - - val optionsToRegister = callToHealthTypes(call) - - val isGranted = GoogleSignIn.hasPermissions( - GoogleSignIn.getLastSignedInAccount(context!!), - optionsToRegister, + } + if (dataSet.dataType == DataType.TYPE_DISTANCE_DELTA) { + for (dataPoint in dataSet.dataPoints) { + totalDistance += dataPoint.getValue(Field.FIELD_DISTANCE).toString().toDouble() + } + } + } + var startTime = Date(session.getStartTime(TimeUnit.MILLISECONDS)) + var endTime = Date(session.getEndTime(TimeUnit.MILLISECONDS)) + + healthData.add( + hashMapOf( + "workoutActivityType" to workoutTypeMap.filterValues { it == session.activity }.keys.first(), + "activity" to session.activity, + "description" to session.description, + "totalEnergyBurned" to if (totalEnergyBurned == 0.0) null else totalEnergyBurned, + "totalDistance" to if (totalDistance == 0.0) null else totalDistance, + "startTime" to iso8601DateFormat.format(startTime), + "endTime" to iso8601DateFormat.format(endTime), + "sessionAppPackageName" to session.appPackageName, + "sessionIdentifier" to session.identifier + ) ) - - result.success(isGranted) + } + activity!!.runOnUiThread { result.success(healthData) } } - private fun onHealthConnectPermissionCallback(permissionGranted: Set) { - if (permissionGranted.isEmpty()) { - mResult?.success(false); - Log.i("FLUTTER_HEALTH", "Access Denied (to Health Connect)!") - - } else { - mResult?.success(true); - Log.i("FLUTTER_HEALTH", "Access Granted (to Health Connect)!") + private fun callToHealthTypes(call: MethodCall): FitnessOptions { + val typesBuilder = FitnessOptions.builder() + val args = call.arguments as HashMap<*, *> + val types = (args["types"] as? ArrayList<*>)?.filterIsInstance() + val permissions = (args["permissions"] as? ArrayList<*>)?.filterIsInstance() + + assert(types != null) + assert(permissions != null) + assert(types!!.count() == permissions!!.count()) + + for ((i, typeKey) in types.withIndex()) { + val access = permissions[i] + val dataType = keyToHealthDataType(typeKey) + when (access) { + 0 -> typesBuilder.addDataType(dataType, FitnessOptions.ACCESS_READ) + 1 -> typesBuilder.addDataType(dataType, FitnessOptions.ACCESS_WRITE) + 2 -> { + typesBuilder.addDataType(dataType, FitnessOptions.ACCESS_READ) + typesBuilder.addDataType(dataType, FitnessOptions.ACCESS_WRITE) + } + else -> throw IllegalArgumentException("Unknown access type $access") + } + if (typeKey == SLEEP) { + typesBuilder.accessSleepSessions(FitnessOptions.ACCESS_READ) + when (access) { + 0 -> typesBuilder.accessSleepSessions(FitnessOptions.ACCESS_READ) + 1 -> typesBuilder.accessSleepSessions(FitnessOptions.ACCESS_WRITE) + 2 -> { + typesBuilder.accessSleepSessions(FitnessOptions.ACCESS_READ) + typesBuilder.accessSleepSessions(FitnessOptions.ACCESS_WRITE) + } + else -> throw IllegalArgumentException("Unknown access type $access") + } + } + if (typeKey == WORKOUT) { + when (access) { + 0 -> typesBuilder.accessActivitySessions(FitnessOptions.ACCESS_READ) + 1 -> typesBuilder.accessActivitySessions(FitnessOptions.ACCESS_WRITE) + 2 -> { + typesBuilder.accessActivitySessions(FitnessOptions.ACCESS_READ) + typesBuilder.accessActivitySessions(FitnessOptions.ACCESS_WRITE) + } + else -> throw IllegalArgumentException("Unknown access type $access") } + } } + return typesBuilder.build() + } - /** - * Requests authorization for the HealthDataTypes - * with the the READ or READ_WRITE permission type. - */ - private fun requestAuthorization(call: MethodCall, result: Result) { - if (useHealthConnectIfAvailable && healthConnectService.isHealthConnectAvailable(activity)) { - activity?.let { - mResult = result - healthConnectService.requestAuthorization(call) - } - return - } - - if (context == null) { - Log.e("Health", "Context is null") - result.success(false) - return - } - - val optionsToRegister = callToHealthTypes(call) - - // Set to false due to bug described in https://github.com/cph-cachet/flutter-plugins/issues/640#issuecomment-1366830132 - val isGranted = false + private fun hasPermissions(call: MethodCall, result: Result) { - // If not granted then ask for permission - if (!isGranted && activity != null) { - mResult = result - GoogleSignIn.requestPermissions( - activity!!, - GOOGLE_FIT_PERMISSIONS_REQUEST_CODE, - GoogleSignIn.getLastSignedInAccount(context!!), - optionsToRegister, - ) - } else { // / Permission already granted - result.success(true) - } + if (activity == null) { + result.success(false) + return } - /** - * Revokes access to Google Fit using the `disableFit`-method. - * - * Note: Using the `revokeAccess` creates a bug on android - * when trying to reapply for permissions afterwards, hence - * `disableFit` was used. - */ - private fun revokePermissions(result: Result) { - if (useHealthConnectIfAvailable && healthConnectService.isHealthConnectAvailable(activity)) { - result.notImplemented() - return - } - if (context == null) { - result.success(false) - return - } - Fitness.getConfigClient(activity!!, GoogleSignIn.getLastSignedInAccount(context!!)!!) - .disableFit() - .addOnSuccessListener { - Log.i("Health", "Disabled Google Fit") - result.success(true) - } - .addOnFailureListener { e -> - Log.w("Health", "There was an error disabling Google Fit", e) - result.success(false) - } - } + val optionsToRegister = callToHealthTypes(call) - private fun getTotalStepsInInterval(call: MethodCall, result: Result) { - val start = call.argument("startTimeSec")!! - val end = call.argument("endTimeSec")!! + val isGranted = GoogleSignIn.hasPermissions( + GoogleSignIn.getLastSignedInAccount(activity!!), + optionsToRegister + ) - if (useHealthConnectIfAvailable && healthConnectService.isHealthConnectAvailable(activity)) { - mainScope.launch { - val steps = healthConnectService.getSteps(start, end) - result.success(steps) - } - return - } + result.success(isGranted) + } - val context = context ?: return - - val stepsDataType = keyToHealthDataType(STEPS) - val aggregatedDataType = keyToHealthDataType(AGGREGATE_STEP_COUNT) - - val fitnessOptions = FitnessOptions.builder() - .addDataType(stepsDataType) - .addDataType(aggregatedDataType) - .build() - val gsa = GoogleSignIn.getAccountForExtension(context, fitnessOptions) - - val ds = DataSource.Builder() - .setAppPackageName("com.google.android.gms") - .setDataType(stepsDataType) - .setType(DataSource.TYPE_DERIVED) - .setStreamName("estimated_steps") - .build() - - val duration = (end - start).toInt() - - val request = DataReadRequest.Builder() - .aggregate(ds) - .bucketByTime(duration, TimeUnit.SECONDS) - .setTimeRange(start, end, TimeUnit.SECONDS) - .build() - - Fitness.getHistoryClient(context, gsa).readData(request) - .addOnFailureListener( - errHandler( - result, - "There was an error getting the total steps in the interval!", - ), - ) - .addOnSuccessListener( - threadPoolExecutor!!, - getStepsInRange(start, end, aggregatedDataType, result), - ) + /// Called when the "requestAuthorization" is invoked from Flutter + private fun requestAuthorization(call: MethodCall, result: Result) { + if (activity == null) { + result.success(false) + return } - private fun getStepsInRange( - start: Long, - end: Long, - aggregatedDataType: DataType, - result: Result, - ) = - OnSuccessListener { response: DataReadResponse -> - val map = HashMap() // need to return to Dart so can't use sparse array - for (bucket in response.buckets) { - val dp = bucket.dataSets.firstOrNull()?.dataPoints?.firstOrNull() - if (dp != null) { - val count = dp.getValue(aggregatedDataType.fields[0]) - - val startTime = dp.getStartTime(TimeUnit.SECONDS) - val startDate = Date(startTime) - val endDate = Date(dp.getEndTime(TimeUnit.SECONDS)) - Log.i( - "FLUTTER_HEALTH::SUCCESS", - "returning $count steps for $startDate - $endDate", - ) - map[startTime] = count.asInt() - } else { - val startDay = Date(start) - val endDay = Date(end) - Log.i("FLUTTER_HEALTH::ERROR", "no steps for $startDay - $endDay") - } - } + val optionsToRegister = callToHealthTypes(call) + mResult = result - assert(map.size <= 1) { "getTotalStepsInInterval should return only one interval. Found: ${map.size}" } - Handler(context!!.mainLooper).run { - result.success(map.values.firstOrNull()) - } + val isGranted = GoogleSignIn.hasPermissions( + GoogleSignIn.getLastSignedInAccount(activity!!), + optionsToRegister + ) + // If not granted then ask for permission + if (!isGranted && activity != null) { + GoogleSignIn.requestPermissions( + activity!!, + GOOGLE_FIT_PERMISSIONS_REQUEST_CODE, + GoogleSignIn.getLastSignedInAccount(activity!!), + optionsToRegister + ) + } else { /// Permission already granted + result.success(true) + } + } + /** + * Revokes access to Google Fit using the `disableFit`-method. + * + * Note: Using the `revokeAccess` creates a bug on android + * when trying to reapply for permissions afterwards, hence + * `disableFit` was used. + */ + private fun revokePermissions(call: MethodCall, result: Result) { + if (activity == null) { + result.success(false) + return + } + Fitness.getConfigClient(activity!!, GoogleSignIn.getLastSignedInAccount(activity!!)!!) + .disableFit() + .addOnSuccessListener { + Log.i("Health","Disabled Google Fit") + result.success(true) + } + .addOnFailureListener { e -> + Log.w("Health", "There was an error disabling Google Fit", e) + result.success(false) + } + } + + private fun getTotalStepsInInterval(call: MethodCall, result: Result) { + val start = call.argument("startTimeSec")!! + val end = call.argument("endTimeSec")!! + + val activity = activity ?: return + + val stepsDataType = keyToHealthDataType(STEPS) + val aggregatedDataType = keyToHealthDataType(AGGREGATE_STEP_COUNT) + + val fitnessOptions = FitnessOptions.builder() + .addDataType(stepsDataType) + .addDataType(aggregatedDataType) + .build() + val gsa = GoogleSignIn.getAccountForExtension(activity, fitnessOptions) + + val ds = DataSource.Builder() + .setAppPackageName("com.google.android.gms") + .setDataType(stepsDataType) + .setType(DataSource.TYPE_DERIVED) + .setStreamName("estimated_steps") + .build() + + val duration = (end - start).toInt() + + val request = DataReadRequest.Builder() + .aggregate(ds) + .bucketByTime(duration, TimeUnit.MILLISECONDS) + .setTimeRange(start, end, TimeUnit.MILLISECONDS) + .build() + + Fitness.getHistoryClient(activity, gsa).readData(request) + .addOnFailureListener(errHandler(result)) + .addOnSuccessListener( + threadPoolExecutor!!, + getStepsInRange(start, end, aggregatedDataType, result) + ) + + } + + + private fun getStepsInRange( + start: Long, + end: Long, + aggregatedDataType: DataType, + result: Result + ) = + OnSuccessListener { response: DataReadResponse -> + + val map = HashMap() // need to return to Dart so can't use sparse array + for (bucket in response.buckets) { + val dp = bucket.dataSets.firstOrNull()?.dataPoints?.firstOrNull() + if (dp != null) { + print(dp) + + val count = dp.getValue(aggregatedDataType.fields[0]) + + val startTime = dp.getStartTime(TimeUnit.SECONDS) + val startDate = Date(startTime) + val endDate = Date(dp.getEndTime(TimeUnit.SECONDS)) + Log.i("FLUTTER_HEALTH::SUCCESS", "returning $count steps for $startDate - $endDate") + map[startTime] = count.asInt() + } else { + val startDay = Date(start) + val endDay = Date(end) + Log.i("FLUTTER_HEALTH::ERROR", "no steps for $startDay - $endDay") } + } - private fun getActivityType(type: String): String { - return workoutTypeMap[type] ?: FitnessActivities.UNKNOWN + assert(map.size <= 1) { "getTotalStepsInInterval should return only one interval. Found: ${map.size}" } + activity!!.runOnUiThread { + result.success(map.values.firstOrNull()) + } } - /** - * Handle calls from the MethodChannel - */ - override fun onMethodCall(call: MethodCall, result: Result) { - when (call.method) { - "useHealthConnectIfAvailable" -> useHealthConnectIfAvailable(result) - "hasPermissions" -> hasPermissions(call, result) - "requestAuthorization" -> requestAuthorization(call, result) - "revokePermissions" -> revokePermissions(result) - "getData" -> getData(call, result) - "writeData" -> writeData(call, result) - "delete" -> delete(call, result) - "getTotalStepsInInterval" -> getTotalStepsInInterval(call, result) - "writeWorkoutData" -> writeWorkoutData(call, result) - "writeBloodPressure" -> writeBloodPressure(call, result) - "writeBloodOxygen" -> writeBloodOxygen(call, result) - else -> result.notImplemented() - } + private fun getActivityType(type: String): String { + return workoutTypeMap[type] ?: FitnessActivities.UNKNOWN + } + + /// Handle calls from the MethodChannel + override fun onMethodCall(call: MethodCall, result: Result) { + when (call.method) { + "requestAuthorization" -> requestAuthorization(call, result) + "revokePermissions" -> revokePermissions(call, result) + "getData" -> getData(call, result) + "writeData" -> writeData(call, result) + "getTotalStepsInInterval" -> getTotalStepsInInterval(call, result) + "hasPermissions" -> hasPermissions(call, result) + "writeWorkoutData" -> writeWorkoutData(call, result) + else -> result.notImplemented() } + } + /// Handle calls from the EventChannel.StreamHandler + override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { + this.logger = events + } - /// Handle calls from the EventChannel.StreamHandler - override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { - this.logger = events - } + override fun onCancel(arguments: Any?) { + this.logger?.endOfStream() + } - override fun onCancel(arguments: Any?) { - this.logger?.endOfStream() + private fun sendInfoLog(message: String) { + activity?.runOnUiThread { + logger?.success(message) } + } - override fun onAttachedToActivity(binding: ActivityPluginBinding) { - if (channel == null) { - return - } - binding.addActivityResultListener(this) - activity = binding.activity - healthConnectService.initiate(binding.activity, ::onHealthConnectPermissionCallback) + private fun sendErrorLog(message: String) { + activity?.runOnUiThread { + logger?.error("Health_Error", message, null) } + } - override fun onDetachedFromActivityForConfigChanges() { - onDetachedFromActivity() + override fun onAttachedToActivity(binding: ActivityPluginBinding) { + if (channel == null) { + return } + binding.addActivityResultListener(this) + activity = binding.activity + } - override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) { - onAttachedToActivity(binding) - } + override fun onDetachedFromActivityForConfigChanges() { + onDetachedFromActivity() + } - override fun onDetachedFromActivity() { - if (channel == null) { - return - } - activity = null - healthConnectService.clear() - } + override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) { + onAttachedToActivity(binding) + } - private fun useHealthConnectIfAvailable(result: Result) { - useHealthConnectIfAvailable = true - result.success(null) + override fun onDetachedFromActivity() { + if (channel == null) { + return } + activity = null + } } diff --git a/packages/health/example/android/app/build.gradle b/packages/health/example/android/app/build.gradle index 26851a0c5..5b4a7bf01 100644 --- a/packages/health/example/android/app/build.gradle +++ b/packages/health/example/android/app/build.gradle @@ -26,7 +26,7 @@ apply plugin: 'kotlin-android' apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" android { - compileSdkVersion 34 + compileSdkVersion 33 sourceSets { main.java.srcDirs += 'src/main/kotlin' @@ -36,7 +36,7 @@ android { defaultConfig { // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). applicationId "com.metaflow.lumen_tng.app" - minSdkVersion 26 + minSdkVersion 16 targetSdkVersion 31 versionCode flutterVersionCode.toInteger() versionName flutterVersionName diff --git a/packages/health/example/android/app/src/main/AndroidManifest.xml b/packages/health/example/android/app/src/main/AndroidManifest.xml index 72856cbb6..cbf39ba9d 100644 --- a/packages/health/example/android/app/src/main/AndroidManifest.xml +++ b/packages/health/example/android/app/src/main/AndroidManifest.xml @@ -1,13 +1,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + - - - - - - - - - - - diff --git a/packages/health/example/android/app/src/main/kotlin/cachet/plugins/example_app/MainActivity.kt b/packages/health/example/android/app/src/main/kotlin/cachet/plugins/example_app/MainActivity.kt index e0908168a..192d069e3 100644 --- a/packages/health/example/android/app/src/main/kotlin/cachet/plugins/example_app/MainActivity.kt +++ b/packages/health/example/android/app/src/main/kotlin/cachet/plugins/example_app/MainActivity.kt @@ -1,6 +1,8 @@ package com.metaflow.lumen_tng.app -import io.flutter.embedding.android.FlutterFragmentActivity +import android.os.Bundle -class MainActivity : FlutterFragmentActivity() { +import io.flutter.embedding.android.FlutterActivity + +class MainActivity: FlutterActivity() { } diff --git a/packages/health/example/android/build.gradle b/packages/health/example/android/build.gradle index 42ca7ac92..6df3db16b 100644 --- a/packages/health/example/android/build.gradle +++ b/packages/health/example/android/build.gradle @@ -1,12 +1,12 @@ buildscript { - ext.kotlin_version = '1.8.0' + ext.kotlin_version = '1.6.10' repositories { google() - mavenCentral() + jcenter() } dependencies { - classpath 'com.android.tools.build:gradle:7.4.0' + classpath 'com.android.tools.build:gradle:7.0.2' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" } } @@ -14,7 +14,7 @@ buildscript { allprojects { repositories { google() - mavenCentral() + jcenter() } } diff --git a/packages/health/example/android/gradle/wrapper/gradle-wrapper.properties b/packages/health/example/android/gradle/wrapper/gradle-wrapper.properties index d25ff03f4..8b6dc5921 100644 --- a/packages/health/example/android/gradle/wrapper/gradle-wrapper.properties +++ b/packages/health/example/android/gradle/wrapper/gradle-wrapper.properties @@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.5-bin.zip \ No newline at end of file +distributionUrl=https\://services.gradle.org/distributions/gradle-7.0.2-all.zip \ No newline at end of file diff --git a/packages/health/example/lib/main.dart b/packages/health/example/lib/main.dart index 05d12f720..1706cbc01 100644 --- a/packages/health/example/lib/main.dart +++ b/packages/health/example/lib/main.dart @@ -1,8 +1,10 @@ import 'dart:async'; +import 'dart:convert'; +import 'dart:math'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:health/health.dart'; -import 'package:health_example/util.dart'; import 'package:permission_handler/permission_handler.dart'; void main() => runApp(HealthApp()); @@ -17,110 +19,110 @@ enum AppState { FETCHING_DATA, DATA_READY, NO_DATA, - AUTHORIZED, AUTH_NOT_GRANTED, DATA_ADDED, - DATA_DELETED, DATA_NOT_ADDED, - DATA_NOT_DELETED, STEPS_READY, } class _HealthAppState extends State { List _healthDataList = []; AppState _state = AppState.DATA_NOT_FETCHED; - int _nofSteps = 0; - - // Define the types to get. - // NOTE: These are only the ones supported on Androids new API Health Connect. - // Both Android's Google Fit and iOS' HealthKit have more types that we support in the enum list [HealthDataType] - // Add more - like AUDIOGRAM, HEADACHE_SEVERE etc. to try them. - static final types = dataTypesAndroid; - - // Or selected types - // static final types = [ - // HealthDataType.WEIGHT, - // HealthDataType.STEPS, - // HealthDataType.HEIGHT, - // HealthDataType.BLOOD_GLUCOSE, - // HealthDataType.WORKOUT, - // HealthDataType.BLOOD_PRESSURE_DIASTOLIC, - // HealthDataType.BLOOD_PRESSURE_SYSTOLIC, - // // Uncomment these lines on iOS - only available on iOS - // // HealthDataType.AUDIOGRAM - // ]; - - // with coresponsing permissions - // READ only - // final permissions = types.map((e) => HealthDataAccess.READ).toList(); - // Or READ and WRITE - final permissions = types.map((e) => HealthDataAccess.READ_WRITE).toList(); + int _nofSteps = 10; + double _mgdl = 10.0; // create a HealthFactory for use in the app - HealthFactory health = HealthFactory(useHealthConnectIfAvailable: true); - - Future authorize() async { - // If we are trying to read Step Count, Workout, Sleep or other data that requires - // the ACTIVITY_RECOGNITION permission, we need to request the permission first. - // This requires a special request authorization call. - // - // The location permission is requested for Workouts using the Distance information. - await Permission.activityRecognition.request(); - await Permission.location.request(); - - // Check if we have permission - // hasPermissions is set to default false, because the hasPermission cannot disclose if WRITE access exists. - // Hence, we have to request with WRITE as well. - bool hasPermissions = await health.hasPermissions(types, permissions: permissions) ?? false; - - bool authorized = false; - if (!hasPermissions) { - // requesting access to the data types before reading them - try { - authorized = await health.requestAuthorization(types, permissions: permissions); - } catch (error) { - print("Exception in authorize: $error"); - } - } - - setState(() => _state = (authorized) ? AppState.AUTHORIZED : AppState.AUTH_NOT_GRANTED); - } + HealthFactory health = HealthFactory(); /// Fetch data points from the health plugin and show them in the app. Future fetchData() async { setState(() => _state = AppState.FETCHING_DATA); + // define the types to get + final types = [ + // HealthDataType.TOTAL_NUTRIENTS, + // HealthDataType.DIETARY_FATS_CONSUMED, + // HealthDataType.DIETARY_PROTEIN_CONSUMED, + // HealthDataType.MENSTRUATION_DATA, + // HealthDataType.WEIGHT, + // HealthDataType.HEIGHT, + // HealthDataType.BLOOD_GLUCOSE, + HealthDataType.WORKOUT, + HealthDataType.SLEEP, + // Uncomment these lines on iOS - only available on iOS + // HealthDataType.AUDIOGRAM, + HealthDataType.STEPS, + ]; + + // with coresponsing permissions + final permissions = [ + // HealthDataAccess.READ, + HealthDataAccess.READ, + HealthDataAccess.READ, + HealthDataAccess.READ, + // HealthDataAccess.READ, + // HealthDataAccess.READ, + // HealthDataAccess.READ, + // HealthDataAccess.READ, + // HealthDataAccess.READ, + // HealthDataAccess.READ, + // HealthDataAccess.READ, + ]; + // get data within the last 24 hours final now = DateTime.now(); - final yesterday = now.subtract(Duration(hours: 24)); - - // Clear old data points - _healthDataList.clear(); + final yesterday = now.subtract(Duration(days: 10)); + // requesting access to the data types before reading them + // note that strictly speaking, the [permissions] are not + // needed, since we only want READ access. + bool requested = false; try { - // fetch health data - List healthData = await health.getHealthDataFromTypes( - startTime: yesterday, - endTime: now, - types: types, - isPriorityQueue: false, - ); - // save all the new data points (only the first 100) - _healthDataList.addAll((healthData.length < 100) ? healthData : healthData.sublist(0, 100)); + requested = await health.requestAuthorization(types, permissions: permissions); } catch (error) { + requested = false; print("Exception in getHealthDataFromTypes: $error"); } - // filter out duplicates - _healthDataList = HealthFactory.removeDuplicates(_healthDataList); + // If we are trying to read Step Count, Workout, Sleep or other data that requires + // the ACTIVITY_RECOGNITION permission, we need to request the permission first. + // This requires a special request authorization call. + // + // The location permission is requested for Workouts using the Distance information. + await Permission.activityRecognition.request(); + await Permission.location.request(); - // print the results - _healthDataList.forEach((x) => print(x)); + if (requested) { + try { + // fetch health data + List healthData = await health.getHealthDataFromTypes( + startTime: yesterday, + endTime: now, + types: types, + isPriorityQueue: false, + ); + + // filter out duplicates + _healthDataList.removeWhere( + (shownElement) => healthData.indexWhere((newElement) => mapEquals(shownElement, newElement)) != -1); + + // save all the new data points (only the first 200) + _healthDataList.addAll((healthData.length < 200) ? healthData : healthData.sublist(0, 200)); + } catch (error) { + print("Exception in getHealthDataFromTypes: $error"); + } - // update the UI to display the results - setState(() { - _state = _healthDataList.isEmpty ? AppState.NO_DATA : AppState.DATA_READY; - }); + // print the results + _healthDataList.forEach((x) => print(x)); + + // update the UI to display the results + setState(() { + _state = _healthDataList.isEmpty ? AppState.NO_DATA : AppState.DATA_READY; + }); + } else { + print("Authorization not granted"); + setState(() => _state = AppState.DATA_NOT_FETCHED); + } } /// Add some random health data. @@ -128,63 +130,30 @@ class _HealthAppState extends State { final now = DateTime.now(); final earlier = now.subtract(Duration(minutes: 20)); - // Add data for supported types - // NOTE: These are only the ones supported on Androids new API Health Connect. - // Both Android's Google Fit and iOS' HealthKit have more types that we support in the enum list [HealthDataType] - // Add more - like AUDIOGRAM, HEADACHE_SEVERE etc. to try them. - bool success = true; - success &= await health.writeHealthData(1.925, HealthDataType.HEIGHT, earlier, now); - success &= await health.writeHealthData(90, HealthDataType.WEIGHT, earlier, now); - success &= await health.writeHealthData(90, HealthDataType.HEART_RATE, earlier, now); - success &= await health.writeHealthData(90, HealthDataType.STEPS, earlier, now); - success &= await health.writeHealthData(200, HealthDataType.ACTIVE_ENERGY_BURNED, earlier, now); - success &= await health.writeHealthData(70, HealthDataType.HEART_RATE, earlier, now); - success &= await health.writeHealthData(37, HealthDataType.BODY_TEMPERATURE, earlier, now); - success &= await health.writeHealthData(105, HealthDataType.BLOOD_GLUCOSE, earlier, now); - success &= await health.writeHealthData(1.8, HealthDataType.WATER, earlier, now); - success &= await health.writeWorkoutData( - HealthWorkoutActivityType.AMERICAN_FOOTBALL, now.subtract(Duration(minutes: 15)), now, - totalDistance: 2430, totalEnergyBurned: 400); - success &= await health.writeHealthData(0.0, HealthDataType.SLEEP_REM, earlier, now); - success &= await health.writeHealthData(0.0, HealthDataType.SLEEP_ASLEEP, earlier, now); - success &= await health.writeHealthData(0.0, HealthDataType.SLEEP_AWAKE, earlier, now); - success &= await health.writeHealthData(0.0, HealthDataType.SLEEP_DEEP, earlier, now); - - // Store an Audiogram - // Uncomment these on iOS - only available on iOS - // const frequencies = [125.0, 500.0, 1000.0, 2000.0, 4000.0, 8000.0]; - // const leftEarSensitivities = [49.0, 54.0, 89.0, 52.0, 77.0, 35.0]; - // const rightEarSensitivities = [76.0, 66.0, 90.0, 22.0, 85.0, 44.5]; - - // success &= await health.writeAudiogram( - // frequencies, - // leftEarSensitivities, - // rightEarSensitivities, - // now, - // now, - // metadata: { - // "HKExternalUUID": "uniqueID", - // "HKDeviceName": "bluetooth headphone", - // }, - // ); + // Store a count of steps taken + _nofSteps = Random().nextInt(10); + bool success = await health.writeHealthData(_nofSteps.toDouble(), HealthDataType.STEPS, earlier, now); - setState(() { - _state = success ? AppState.DATA_ADDED : AppState.DATA_NOT_ADDED; - }); - } + // Store a height + success &= await health.writeHealthData(1.93, HealthDataType.HEIGHT, earlier, now); - /// Delete some random health data. - Future deleteData() async { - final now = DateTime.now(); - final earlier = now.subtract(Duration(hours: 24)); + // Store a Blood Glucose measurement + _mgdl = Random().nextInt(10) * 1.0; + success &= await health.writeHealthData(_mgdl, HealthDataType.BLOOD_GLUCOSE, now, now); - bool success = true; - for (HealthDataType type in types) { - success &= await health.delete(type, earlier, now); - } + // Store a workout eg. running + success &= await health.writeWorkoutData( + HealthWorkoutActivityType.RUNNING, earlier, now, + // The following are optional parameters + // and the UNITS are functional on iOS ONLY! + totalEnergyBurned: 230, + totalEnergyBurnedUnit: HealthDataUnit.KILOCALORIE, + totalDistance: 1234, + totalDistanceUnit: HealthDataUnit.FOOT, + ); setState(() { - _state = success ? AppState.DATA_DELETED : AppState.DATA_NOT_DELETED; + _state = success ? AppState.DATA_ADDED : AppState.DATA_NOT_ADDED; }); } @@ -217,14 +186,6 @@ class _HealthAppState extends State { } } - Future revokeAccess() async { - try { - await health.revokePermissions(); - } catch (error) { - print("Caught exception in revokeAccess: $error"); - } - } - Widget _contentFetchingData() { return Column( mainAxisAlignment: MainAxisAlignment.center, @@ -244,9 +205,11 @@ class _HealthAppState extends State { itemCount: _healthDataList.length, itemBuilder: (_, index) { HealthDataPoint p = _healthDataList[index]; - String printedValue = p.entries.map((e) => "${e.key}: ${e.value}").join("; "); + final str = json.encode(p); return ListTile( - title: Text(printedValue), + title: Text(""), + trailing: Text(''), + subtitle: Text('$str'), ); }); } @@ -266,10 +229,6 @@ class _HealthAppState extends State { ); } - Widget _authorized() { - return Text('Authorization granted!'); - } - Widget _authorizationNotGranted() { return Text('Authorization not given. ' 'For Android please check your OAUTH2 client ID is correct in Google Developer Console. ' @@ -280,10 +239,6 @@ class _HealthAppState extends State { return Text('Data points inserted successfully!'); } - Widget _dataDeleted() { - return Text('Data points deleted successfully!'); - } - Widget _stepsFetched() { return Text('Total number of steps: $_nofSteps'); } @@ -292,10 +247,6 @@ class _HealthAppState extends State { return Text('Failed to add data'); } - Widget _dataNotDeleted() { - return Text('Failed to delete data'); - } - Widget _content() { if (_state == AppState.DATA_READY) return _contentDataReady(); @@ -303,69 +254,47 @@ class _HealthAppState extends State { return _contentNoData(); else if (_state == AppState.FETCHING_DATA) return _contentFetchingData(); - else if (_state == AppState.AUTHORIZED) - return _authorized(); else if (_state == AppState.AUTH_NOT_GRANTED) return _authorizationNotGranted(); else if (_state == AppState.DATA_ADDED) return _dataAdded(); - else if (_state == AppState.DATA_DELETED) - return _dataDeleted(); else if (_state == AppState.STEPS_READY) return _stepsFetched(); - else if (_state == AppState.DATA_NOT_ADDED) - return _dataNotAdded(); - else if (_state == AppState.DATA_NOT_DELETED) - return _dataNotDeleted(); - else - return _contentNotFetched(); + else if (_state == AppState.DATA_NOT_ADDED) return _dataNotAdded(); + + return _contentNotFetched(); } @override Widget build(BuildContext context) { return MaterialApp( home: Scaffold( - appBar: AppBar( - title: const Text('Health Example'), - ), - body: Container( - child: Column( - children: [ - Wrap( - spacing: 10, - children: [ - TextButton( - onPressed: authorize, - child: Text("Auth", style: TextStyle(color: Colors.white)), - style: ButtonStyle(backgroundColor: MaterialStatePropertyAll(Colors.blue))), - TextButton( - onPressed: fetchData, - child: Text("Fetch Data", style: TextStyle(color: Colors.white)), - style: ButtonStyle(backgroundColor: MaterialStatePropertyAll(Colors.blue))), - TextButton( - onPressed: addData, - child: Text("Add Data", style: TextStyle(color: Colors.white)), - style: ButtonStyle(backgroundColor: MaterialStatePropertyAll(Colors.blue))), - TextButton( - onPressed: deleteData, - child: Text("Delete Data", style: TextStyle(color: Colors.white)), - style: ButtonStyle(backgroundColor: MaterialStatePropertyAll(Colors.blue))), - TextButton( - onPressed: fetchStepData, - child: Text("Fetch Step Data", style: TextStyle(color: Colors.white)), - style: ButtonStyle(backgroundColor: MaterialStatePropertyAll(Colors.blue))), - TextButton( - onPressed: revokeAccess, - child: Text("Revoke Access", style: TextStyle(color: Colors.white)), - style: ButtonStyle(backgroundColor: MaterialStatePropertyAll(Colors.blue))), - ], + appBar: AppBar( + title: const Text('Health Example'), + actions: [ + IconButton( + icon: Icon(Icons.file_download), + onPressed: () { + fetchData(); + }, + ), + IconButton( + onPressed: () { + addData(); + }, + icon: Icon(Icons.add), ), - Divider(thickness: 3), - Expanded(child: Center(child: _content())) + IconButton( + onPressed: () { + fetchStepData(); + }, + icon: Icon(Icons.nordic_walking), + ) ], ), - ), - ), + body: Center( + child: _content(), + )), ); } } diff --git a/packages/health/example/lib/util.dart b/packages/health/example/lib/util.dart deleted file mode 100644 index 41aff05f7..000000000 --- a/packages/health/example/lib/util.dart +++ /dev/null @@ -1,81 +0,0 @@ -import 'package:health/health.dart'; - -/// List of data types available on iOS -const List dataTypesIOS = [ - HealthDataType.ACTIVE_ENERGY_BURNED, - HealthDataType.AUDIOGRAM, - HealthDataType.BASAL_ENERGY_BURNED, - HealthDataType.BLOOD_GLUCOSE, - HealthDataType.BLOOD_OXYGEN, - HealthDataType.BLOOD_PRESSURE_DIASTOLIC, - HealthDataType.BLOOD_PRESSURE_SYSTOLIC, - HealthDataType.BODY_FAT_PERCENTAGE, - HealthDataType.BODY_MASS_INDEX, - HealthDataType.BODY_TEMPERATURE, - HealthDataType.DIETARY_CARBS_CONSUMED, - HealthDataType.DIETARY_ENERGY_CONSUMED, - HealthDataType.DIETARY_FATS_CONSUMED, - HealthDataType.DIETARY_PROTEIN_CONSUMED, - HealthDataType.ELECTRODERMAL_ACTIVITY, - HealthDataType.FORCED_EXPIRATORY_VOLUME, - HealthDataType.HEART_RATE, - HealthDataType.HEART_RATE_VARIABILITY_SDNN, - HealthDataType.HEIGHT, - HealthDataType.HIGH_HEART_RATE_EVENT, - HealthDataType.RESPIRATORY_RATE, - HealthDataType.PERIPHERAL_PERFUSION_INDEX, - HealthDataType.IRREGULAR_HEART_RATE_EVENT, - HealthDataType.LOW_HEART_RATE_EVENT, - HealthDataType.RESTING_HEART_RATE, - HealthDataType.STEPS, - HealthDataType.WAIST_CIRCUMFERENCE, - HealthDataType.WALKING_HEART_RATE, - HealthDataType.WEIGHT, - HealthDataType.FLIGHTS_CLIMBED, - HealthDataType.DISTANCE_WALKING_RUNNING, - HealthDataType.MINDFULNESS, - HealthDataType.SLEEP_AWAKE, - HealthDataType.SLEEP_ASLEEP, - HealthDataType.SLEEP_IN_BED, - HealthDataType.SLEEP_DEEP, - HealthDataType.SLEEP_REM, - HealthDataType.WATER, - HealthDataType.EXERCISE_TIME, - HealthDataType.WORKOUT, - HealthDataType.HEADACHE_NOT_PRESENT, - HealthDataType.HEADACHE_MILD, - HealthDataType.HEADACHE_MODERATE, - HealthDataType.HEADACHE_SEVERE, - HealthDataType.HEADACHE_UNSPECIFIED, - //HealthDataType.ELECTROCARDIOGRAM, -]; - -/// List of data types available on Android -const List dataTypesAndroid = [ - HealthDataType.ACTIVE_ENERGY_BURNED, - // HealthDataType.BASAL_ENERGY_BURNED, - HealthDataType.BLOOD_GLUCOSE, - HealthDataType.BLOOD_OXYGEN, - HealthDataType.BLOOD_PRESSURE_DIASTOLIC, - HealthDataType.BLOOD_PRESSURE_SYSTOLIC, - HealthDataType.BODY_FAT_PERCENTAGE, - HealthDataType.HEIGHT, - HealthDataType.WEIGHT, - // HealthDataType.BODY_MASS_INDEX, - HealthDataType.BODY_TEMPERATURE, - HealthDataType.HEART_RATE, - HealthDataType.STEPS, - // HealthDataType.MOVE_MINUTES, // TODO: Find alternative for Health Connect - HealthDataType.DISTANCE_DELTA, - // HealthDataType.RESPIRATORY_RATE, - // HealthDataType.SLEEP_AWAKE, - // HealthDataType.SLEEP_ASLEEP, - // HealthDataType.SLEEP_LIGHT, - // HealthDataType.SLEEP_DEEP, - // HealthDataType.SLEEP_REM, - // HealthDataType.SLEEP_SESSION, - HealthDataType.WATER, - HealthDataType.WORKOUT, - // HealthDataType.RESTING_HEART_RATE, - // HealthDataType.FLIGHTS_CLIMBED, -]; diff --git a/packages/health/lib/health.dart b/packages/health/lib/health.dart index 9218f5d3c..cfa924434 100644 --- a/packages/health/lib/health.dart +++ b/packages/health/lib/health.dart @@ -1,7 +1,6 @@ library health; import 'dart:async'; -import 'dart:collection'; import 'dart:io' show Platform; import 'package:device_info_plus/device_info_plus.dart'; diff --git a/packages/health/lib/src/data_types.dart b/packages/health/lib/src/data_types.dart index 5918c850b..bcb813171 100644 --- a/packages/health/lib/src/data_types.dart +++ b/packages/health/lib/src/data_types.dart @@ -9,6 +9,7 @@ enum HealthDataType { BLOOD_OXYGEN, BLOOD_PRESSURE_DIASTOLIC, BLOOD_PRESSURE_SYSTOLIC, + MENSTRUATION_DATA, BODY_FAT_PERCENTAGE, BODY_MASS_INDEX, BODY_TEMPERATURE, @@ -21,9 +22,9 @@ enum HealthDataType { HEART_RATE_VARIABILITY_SDNN, HEIGHT, RESTING_HEART_RATE, - RESPIRATORY_RATE, - PERIPHERAL_PERFUSION_INDEX, STEPS, + GENDER, + BIRTH_DATE, WAIST_CIRCUMFERENCE, WALKING_HEART_RATE, WEIGHT, @@ -33,14 +34,20 @@ enum HealthDataType { DISTANCE_DELTA, MINDFULNESS, WATER, - SLEEP_IN_BED, - SLEEP_ASLEEP, - SLEEP_AWAKE, - SLEEP_LIGHT, - SLEEP_DEEP, - SLEEP_REM, - SLEEP_OUT_OF_BED, - SLEEP_SESSION, + + /// Sleep granular data returned as integers, which can be mapped into this values on Android: + /// val SLEEP_STAGE_NAMES = arrayOf( + // "Unused", + // "Awake (during sleep)", + // "Sleep", + // "Out-of-bed", + // "Light sleep", + // "Deep sleep", + // "REM sleep" + // ) + /// and on iOS: TODO - find out what the values are on iOS + SLEEP, + EXERCISE_TIME, WORKOUT, HEADACHE_NOT_PRESENT, @@ -54,15 +61,9 @@ enum HealthDataType { LOW_HEART_RATE_EVENT, IRREGULAR_HEART_RATE_EVENT, ELECTRODERMAL_ACTIVITY, - ELECTROCARDIOGRAM, - - MENSTRUATION_DATA, - GENDER, - BIRTH_DATE, TOTAL_NUTRIENTS, } -/// Access types for Health Data. enum HealthDataAccess { READ, WRITE, @@ -94,8 +95,6 @@ const List _dataTypeKeysIOS = [ HealthDataType.IRREGULAR_HEART_RATE_EVENT, HealthDataType.LOW_HEART_RATE_EVENT, HealthDataType.RESTING_HEART_RATE, - HealthDataType.RESPIRATORY_RATE, - HealthDataType.PERIPHERAL_PERFUSION_INDEX, HealthDataType.STEPS, HealthDataType.WAIST_CIRCUMFERENCE, HealthDataType.WALKING_HEART_RATE, @@ -103,11 +102,7 @@ const List _dataTypeKeysIOS = [ HealthDataType.FLIGHTS_CLIMBED, HealthDataType.DISTANCE_WALKING_RUNNING, HealthDataType.MINDFULNESS, - HealthDataType.SLEEP_IN_BED, - HealthDataType.SLEEP_AWAKE, - HealthDataType.SLEEP_ASLEEP, - HealthDataType.SLEEP_DEEP, - HealthDataType.SLEEP_REM, + HealthDataType.SLEEP, HealthDataType.WATER, HealthDataType.EXERCISE_TIME, HealthDataType.WORKOUT, @@ -116,7 +111,6 @@ const List _dataTypeKeysIOS = [ HealthDataType.HEADACHE_MODERATE, HealthDataType.HEADACHE_SEVERE, HealthDataType.HEADACHE_UNSPECIFIED, - HealthDataType.ELECTROCARDIOGRAM, HealthDataType.MENSTRUATION_DATA, HealthDataType.BIRTH_DATE, HealthDataType.GENDER, @@ -138,19 +132,9 @@ const List _dataTypeKeysAndroid = [ HealthDataType.WEIGHT, HealthDataType.MOVE_MINUTES, HealthDataType.DISTANCE_DELTA, - HealthDataType.SLEEP_AWAKE, - HealthDataType.SLEEP_ASLEEP, - HealthDataType.SLEEP_DEEP, - HealthDataType.SLEEP_LIGHT, - HealthDataType.SLEEP_REM, - HealthDataType.SLEEP_OUT_OF_BED, - HealthDataType.SLEEP_SESSION, + HealthDataType.SLEEP, HealthDataType.WATER, HealthDataType.WORKOUT, - HealthDataType.RESTING_HEART_RATE, - HealthDataType.FLIGHTS_CLIMBED, - HealthDataType.BASAL_ENERGY_BURNED, - HealthDataType.RESPIRATORY_RATE, HealthDataType.TOTAL_NUTRIENTS, HealthDataType.MENSTRUATION_DATA, ]; @@ -174,8 +158,6 @@ const Map _dataTypeToUnit = { HealthDataType.ELECTRODERMAL_ACTIVITY: HealthDataUnit.SIEMEN, HealthDataType.FORCED_EXPIRATORY_VOLUME: HealthDataUnit.LITER, HealthDataType.HEART_RATE: HealthDataUnit.BEATS_PER_MINUTE, - HealthDataType.RESPIRATORY_RATE: HealthDataUnit.RESPIRATIONS_PER_MINUTE, - HealthDataType.PERIPHERAL_PERFUSION_INDEX: HealthDataUnit.PERCENT, HealthDataType.HEIGHT: HealthDataUnit.METER, HealthDataType.RESTING_HEART_RATE: HealthDataUnit.BEATS_PER_MINUTE, HealthDataType.STEPS: HealthDataUnit.COUNT, @@ -188,15 +170,7 @@ const Map _dataTypeToUnit = { HealthDataType.DISTANCE_DELTA: HealthDataUnit.METER, HealthDataType.WATER: HealthDataUnit.LITER, - HealthDataType.SLEEP_IN_BED: HealthDataUnit.MINUTE, - HealthDataType.SLEEP_ASLEEP: HealthDataUnit.MINUTE, - HealthDataType.SLEEP_AWAKE: HealthDataUnit.MINUTE, - HealthDataType.SLEEP_DEEP: HealthDataUnit.MINUTE, - HealthDataType.SLEEP_REM: HealthDataUnit.MINUTE, - HealthDataType.SLEEP_OUT_OF_BED: HealthDataUnit.MINUTE, - HealthDataType.SLEEP_LIGHT: HealthDataUnit.MINUTE, - HealthDataType.SLEEP_SESSION: HealthDataUnit.MINUTE, - + HealthDataType.SLEEP: HealthDataUnit.MINUTE, HealthDataType.MINDFULNESS: HealthDataUnit.MINUTE, HealthDataType.EXERCISE_TIME: HealthDataUnit.MINUTE, HealthDataType.WORKOUT: HealthDataUnit.NO_UNIT, @@ -206,15 +180,13 @@ const Map _dataTypeToUnit = { HealthDataType.HEADACHE_MODERATE: HealthDataUnit.MINUTE, HealthDataType.HEADACHE_SEVERE: HealthDataUnit.MINUTE, HealthDataType.HEADACHE_UNSPECIFIED: HealthDataUnit.MINUTE, + HealthDataType.MENSTRUATION_DATA: HealthDataUnit.COUNT, // Heart Rate events (specific to Apple Watch) HealthDataType.HIGH_HEART_RATE_EVENT: HealthDataUnit.NO_UNIT, HealthDataType.LOW_HEART_RATE_EVENT: HealthDataUnit.NO_UNIT, HealthDataType.IRREGULAR_HEART_RATE_EVENT: HealthDataUnit.NO_UNIT, HealthDataType.HEART_RATE_VARIABILITY_SDNN: HealthDataUnit.MILLISECOND, - HealthDataType.ELECTROCARDIOGRAM: HealthDataUnit.VOLT, - - HealthDataType.MENSTRUATION_DATA: HealthDataUnit.COUNT, HealthDataType.TOTAL_NUTRIENTS: HealthDataUnit.NO_UNIT, HealthDataType.GENDER: HealthDataUnit.NO_UNIT, HealthDataType.BIRTH_DATE: HealthDataUnit.NO_UNIT, @@ -222,7 +194,7 @@ const Map _dataTypeToUnit = { const PlatformTypeJsonValue = { PlatformType.IOS: 'ios', - PlatformType.ANDROID: 'android', + PlatformType.ANDROID: 'android' }; /// List of all [HealthDataUnit]s. @@ -300,7 +272,6 @@ enum HealthDataUnit { // Other units BEATS_PER_MINUTE, - RESPIRATIONS_PER_MINUTE, MILLIGRAM_PER_DECILITER, UNKNOWN_UNIT, NO_UNIT, @@ -393,12 +364,6 @@ enum HealthWorkoutActivityType { // Android only AEROBICS, BIATHLON, - BIKING_HAND, - BIKING_MOUNTAIN, - BIKING_ROAD, - BIKING_SPINNING, - BIKING_STATIONARY, - BIKING_UTILITY, CALISTHENICS, CIRCUIT_TRAINING, CROSS_FIT, @@ -414,7 +379,6 @@ enum HealthWorkoutActivityType { HOUSEWORK, INTERVAL_TRAINING, IN_VEHICLE, - ICE_SKATING, KAYAKING, KETTLEBELL_TRAINING, KICK_SCOOTER, @@ -425,7 +389,6 @@ enum HealthWorkoutActivityType { PARAGLIDING, POLO, ROCK_CLIMBING, // on iOS this is the same as CLIMBING - ROWING_MACHINE, RUNNING_JOGGING, // on iOS this is the same as RUNNING RUNNING_SAND, // on iOS this is the same as RUNNING RUNNING_TREADMILL, // on iOS this is the same as RUNNING @@ -433,13 +396,10 @@ enum HealthWorkoutActivityType { SKATING_CROSS, // on iOS this is the same as SKATING SKATING_INDOOR, // on iOS this is the same as SKATING SKATING_INLINE, // on iOS this is the same as SKATING - SKIING, SKIING_BACK_COUNTRY, SKIING_KITE, SKIING_ROLLER, SLEDDING, - SNOWMOBILE, - SNOWSHOEING, STAIR_CLIMBING_MACHINE, STANDUP_PADDLEBOARDING, STILL, @@ -464,40 +424,3 @@ enum HealthWorkoutActivityType { // OTHER, } - -/// Classifications for ECG readings. -enum ElectrocardiogramClassification { - NOT_SET, - SINUS_RHYTHM, - ATRIAL_FIBRILLATION, - INCONCLUSIVE_LOW_HEART_RATE, - INCONCLUSIVE_HIGH_HEART_RATE, - INCONCLUSIVE_POOR_READING, - INCONCLUSIVE_OTHER, - UNRECOGNIZED, -} - -/// Extension to assign numbers to [ElectrocardiogramClassification]s -extension ElectrocardiogramClassificationValue -on ElectrocardiogramClassification { - int get value { - switch (this) { - case ElectrocardiogramClassification.NOT_SET: - return 0; - case ElectrocardiogramClassification.SINUS_RHYTHM: - return 1; - case ElectrocardiogramClassification.ATRIAL_FIBRILLATION: - return 2; - case ElectrocardiogramClassification.INCONCLUSIVE_LOW_HEART_RATE: - return 3; - case ElectrocardiogramClassification.INCONCLUSIVE_HIGH_HEART_RATE: - return 4; - case ElectrocardiogramClassification.INCONCLUSIVE_POOR_READING: - return 5; - case ElectrocardiogramClassification.INCONCLUSIVE_OTHER: - return 6; - case ElectrocardiogramClassification.UNRECOGNIZED: - return 100; - } - } -} diff --git a/packages/health/lib/src/health_factory.dart b/packages/health/lib/src/health_factory.dart index e45d6492e..fbf59cb21 100644 --- a/packages/health/lib/src/health_factory.dart +++ b/packages/health/lib/src/health_factory.dart @@ -31,7 +31,6 @@ class DataPointInput { /// * reading health data using the [getHealthDataFromTypes] method. /// * writing health data using the [writeHealthData] method. /// * accessing total step counts using the [getTotalStepsInInterval] method. -/// * cleaning up duplicate data points via the [removeDuplicates] method. class HealthFactory { static const EventChannel _logsChannel = const EventChannel('flutter_health_logs_channel'); static const MethodChannel _channel = MethodChannel('flutter_health'); @@ -41,10 +40,6 @@ class HealthFactory { static PlatformType _platformType = Platform.isAndroid ? PlatformType.ANDROID : PlatformType.IOS; - HealthFactory({bool useHealthConnectIfAvailable = false}) { - if (useHealthConnectIfAvailable) _channel.invokeMethod('useHealthConnectIfAvailable'); - } - /// Check if a given data type is available on the platform bool isDataTypeAvailable(HealthDataType dataType) => _platformType == PlatformType.ANDROID ? _dataTypeKeysAndroid.contains(dataType) @@ -93,7 +88,7 @@ class HealthFactory { /// with a READ or READ_WRITE access. /// /// On Android, this function returns true or false, depending on whether the specified access right has been granted. - Future hasPermissions(List types, {List? permissions}) async { + static Future hasPermissions(List types, {List? permissions}) async { if (permissions != null && permissions.length != types.length) throw ArgumentError("The lists of types and permissions must be of same length."); @@ -114,18 +109,9 @@ class HealthFactory { /// Revokes permissions of all types. /// Uses `disableFit()` on Google Fit. /// - /// Not implemented on iOS as there is no way to programmatically remove access. + /// Not supported on iOS Future revokePermissions() async { - try { - if (_platformType == PlatformType.IOS) { - throw UnsupportedError( - 'Revoke permissions is not supported on iOS. Please revoke permissions manually in the settings.'); - } - await _channel.invokeMethod('revokePermissions'); - return; - } catch (e) { - print(e); - } + return await _channel.invokeMethod('revokePermissions'); } /// Requests permissions to access data types in Apple Health or Google Fit. @@ -160,22 +146,6 @@ class HealthFactory { } } - if (permissions != null) { - for (int i = 0; i < types.length; i++) { - final type = types[i]; - final permission = permissions[i]; - if ((type == HealthDataType.ELECTROCARDIOGRAM || - type == HealthDataType.HIGH_HEART_RATE_EVENT || - type == HealthDataType.LOW_HEART_RATE_EVENT || - type == HealthDataType.IRREGULAR_HEART_RATE_EVENT || - type == HealthDataType.WALKING_HEART_RATE) && - permission != HealthDataAccess.READ) { - throw HealthException(type, - 'Requesting WRITE permission on ELECTROCARDIOGRAM / HIGH_HEART_RATE_EVENT / LOW_HEART_RATE_EVENT / IRREGULAR_HEART_RATE_EVENT / WALKING_HEART_RATE is not allowed.'); - } - } - } - final mTypes = List.from(types, growable: true); final mPermissions = permissions == null ? List.filled(types.length, HealthDataAccess.READ.index, growable: true) @@ -261,9 +231,7 @@ class HealthFactory { // Align values to type in cases where the type defines the value. // E.g. SLEEP_IN_BED should have value 0 - if (type == HealthDataType.SLEEP_ASLEEP || - type == HealthDataType.SLEEP_AWAKE || - type == HealthDataType.SLEEP_IN_BED || + if (type == HealthDataType.SLEEP || type == HealthDataType.HEADACHE_NOT_PRESENT || type == HealthDataType.HEADACHE_MILD || type == HealthDataType.HEADACHE_MODERATE || @@ -283,28 +251,6 @@ class HealthFactory { return success ?? false; } - /// Deletes all records of the given type for a given period of time - /// - /// Returns true if successful, false otherwise. - /// - /// Parameters: - /// * [type] - the value's HealthDataType - /// * [startTime] - the start time when this [value] is measured. - /// + It must be equal to or earlier than [endTime]. - /// * [endTime] - the end time when this [value] is measured. - /// + It must be equal to or later than [startTime]. - Future delete(HealthDataType type, DateTime startTime, DateTime endTime) async { - if (startTime.isAfter(endTime)) throw ArgumentError("startTime must be equal or earlier than endTime"); - - Map args = { - 'dataTypeKey': type.name, - 'startTimeSec': startTime.millisecondsSinceEpoch ~/ 1000, - 'endTimeSec': endTime.millisecondsSinceEpoch ~/ 1000, - }; - bool? success = await _channel.invokeMethod('delete', args); - return success ?? false; - } - /// Saves audiogram into Apple Health. /// /// Returns true if successful, false otherwise. @@ -383,9 +329,18 @@ class HealthFactory { /// The main function for fetching health data Future> _dataQuery( - DateTime startTime, DateTime endTime, HealthDataType dataType, bool isPriorityQueue) async { + DateTime startTime, + DateTime endTime, + HealthDataType dataType, + bool isPriorityQueue, + ) async { final _completer = new Completer>(); - final hours = _divideDateRangeIntoHours(startTime, endTime); + + /// We have an issue with fetching sleep from android divided into hours, so we make an exception here + final hours = (dataType == HealthDataType.SLEEP && Platform.isAndroid) + ? [Pair(startTime, endTime)] + : _divideDateRangeIntoHours(startTime, endTime); + final dataPointInput = DataPointInput( completer: _completer, dateRanges: hours, @@ -405,37 +360,38 @@ class HealthFactory { bool processing = false; Future _processRequests() async { - if (processing) { + if (processing || requests.isEmpty) { return; } processing = true; - if (requests.isNotEmpty) { - final List result = []; - - final requestToProcess = requests.first; - - try { - for (var dates in requestToProcess.dateRanges) { - final args = { - 'dataTypeKey': requestToProcess.type.name, - 'dataUnitKey': _dataTypeToUnit[requestToProcess.type]!.name, - 'startTimeSec': dates.first.millisecondsSinceEpoch ~/ 1000, - 'endTimeSec': dates.second.millisecondsSinceEpoch ~/ 1000, - }; - - List? fetchedDataPoints = await _channel.invokeListMethod('getData', args); - if (fetchedDataPoints != null) { - result.addAll(_parse(dataType: requestToProcess.type, dataPoints: fetchedDataPoints)); - } else { - result.addAll([]); - } - } - requestToProcess.completer.complete(result); - } catch (e, st) { - requestToProcess.completer.completeError(e, st); + final List result = []; + + final requestToProcess = requests.first; + + try { + for (var dates in requestToProcess.dateRanges) { + final args = { + 'dataTypeKey': requestToProcess.type.name, + 'dataUnitKey': _dataTypeToUnit[requestToProcess.type]!.name, + 'startTimeSec': dates.first.millisecondsSinceEpoch ~/ 1000, + 'endTimeSec': dates.second.millisecondsSinceEpoch ~/ 1000, + }; + + List? fetchedDataPoints = await _channel.invokeListMethod('getData', args); + if (fetchedDataPoints != null) { + result.addAll(_parse(dataType: requestToProcess.type, dataPoints: fetchedDataPoints)); + } else { + result.addAll([]); + } } + + requestToProcess.completer.complete(result); + } catch (e, st) { + requestToProcess.completer.completeError(e, st); } + + requests.removeAt(0); processing = false; await _processRequests(); } @@ -492,19 +448,10 @@ class HealthFactory { return stepsCount; } - /// Assigns numbers to specific [HealthDataType]s. int _alignValue(HealthDataType type) { switch (type) { - case HealthDataType.SLEEP_IN_BED: + case HealthDataType.SLEEP: return 0; - case HealthDataType.SLEEP_ASLEEP: - return 1; - case HealthDataType.SLEEP_AWAKE: - return 2; - case HealthDataType.SLEEP_DEEP: - return 3; - case HealthDataType.SLEEP_REM: - return 4; case HealthDataType.HEADACHE_UNSPECIFIED: return 0; case HealthDataType.HEADACHE_NOT_PRESENT: @@ -561,12 +508,6 @@ class HealthFactory { return success ?? false; } - /// Given an array of [HealthDataPoint]s, this method will return the array - /// without any duplicates. - static List removeDuplicates(List points) { - return LinkedHashSet.of(points).toList(); - } - /// Check if the given [HealthWorkoutActivityType] is supported on the iOS platform bool _isOnIOS(HealthWorkoutActivityType type) { // Returns true if the type is part of the iOS set