From 218ea143c4a206d313c8e605b8e119bf097ebb69 Mon Sep 17 00:00:00 2001 From: Mo'men amin Date: Tue, 15 Jul 2025 00:03:24 +0200 Subject: [PATCH 1/4] Update HealthPlugin.kt --- .../kotlin/cachet/plugins/health/HealthPlugin.kt | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) 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 b51e83afa..a99d69ea0 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 @@ -2504,8 +2504,20 @@ class HealthPlugin(private var channel: MethodChannel? = null) : } private fun stopStepSensorBackgroundService(call: MethodCall, result: Result) { - val serviceIntent = Intent(activity!!, StepCounterService::class.java) - activity!!.stopService(serviceIntent) + try { + if (activity == null || context == null) { + result.success(false) + return + } + val serviceIntent = Intent(activity!!, StepCounterService::class.java) + val stopped = activity!!.stopService(serviceIntent) + + result.success(stopped) + } catch (e: Exception) { + Log.e("FLUTTER_HEALTH::ERROR", "Failed to stop step sensor service: ${e.message}") + Log.e("FLUTTER_HEALTH::ERROR", e.stackTrace.toString()) + result.success(false) + } } private fun startStepSensorBackgroundService(call: MethodCall, result: Result) { From 047a9a19c7805c541a9cbae708d8ee9dcebd892b Mon Sep 17 00:00:00 2001 From: Arman Soudi Date: Mon, 4 Aug 2025 20:43:00 +0200 Subject: [PATCH 2/4] bg-hc-impl --- packages/health/android/build.gradle | 2 +- .../cachet/plugins/health/HealthPlugin.kt | 65 ++++++++++++++++--- 2 files changed, 56 insertions(+), 11 deletions(-) diff --git a/packages/health/android/build.gradle b/packages/health/android/build.gradle index cd5e3e2aa..15e49fbec 100644 --- a/packages/health/android/build.gradle +++ b/packages/health/android/build.gradle @@ -70,7 +70,7 @@ dependencies { 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-alpha07") + implementation("androidx.health.connect:connect-client:1.1.0-alpha11") def fragment_version = "1.6.2" implementation "androidx.fragment:fragment-ktx:$fragment_version" 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 a99d69ea0..0b7888395 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 @@ -20,8 +20,11 @@ import androidx.annotation.NonNull import androidx.annotation.RequiresApi import androidx.core.content.ContextCompat import androidx.health.connect.client.HealthConnectClient +import androidx.health.connect.client.HealthConnectFeatures import androidx.health.connect.client.PermissionController +import androidx.health.connect.client.feature.ExperimentalFeatureAvailabilityApi import androidx.health.connect.client.permission.HealthPermission +import androidx.health.connect.client.permission.HealthPermission.Companion.PERMISSION_READ_HEALTH_DATA_IN_BACKGROUND import androidx.health.connect.client.records.* import androidx.health.connect.client.records.MealType.MEAL_TYPE_BREAKFAST import androidx.health.connect.client.records.MealType.MEAL_TYPE_DINNER @@ -1476,15 +1479,19 @@ class HealthPlugin(private var channel: MethodChannel? = null) : val healthConnectData = mutableListOf>() - if(dataType == STEPS && StepCounterService.initiated()) { - val items = StepCounterService.box.boxFor(SensorStep::class.java).query(SensorStep_.startTime.between(startTime.toEpochMilli(),endTime.toEpochMilli())).build().find() + if (dataType == STEPS && StepCounterService.initiated()) { + val items = StepCounterService.box.boxFor(SensorStep::class.java).query( + SensorStep_.startTime.between( + startTime.toEpochMilli(), + endTime.toEpochMilli() + ) + ).build().find() healthConnectData.addAll(items.map { mapOf( "value" to it.count, "date_from" to - it.startTime!! - , + it.startTime!!, "date_to" to it.endTime!!, "source_id" to "", @@ -1502,7 +1509,9 @@ class HealthPlugin(private var channel: MethodChannel? = null) : val startTime = Instant.ofEpochMilli(call.argument("startTime")!!) val endTime = Instant.ofEpochMilli(call.argument("endTime")!!) - val items = StepCounterService.box.boxFor(SensorStep::class.java).query(SensorStep_.startTime.between(startTime.toEpochMilli(),endTime.toEpochMilli())).build().find() + val items = StepCounterService.box.boxFor(SensorStep::class.java) + .query(SensorStep_.startTime.between(startTime.toEpochMilli(), endTime.toEpochMilli())) + .build().find() var totalResult = 0.0 items.forEach { totalResult += it.count; @@ -2465,6 +2474,7 @@ class HealthPlugin(private var channel: MethodChannel? = null) : "hasPermissions" -> hasPermissions(call, result) "requestAuthorization" -> requestAuthorization(call, result) "revokePermissions" -> revokePermissions(call, result) + "requestBackgroundFetchIfAvailable" -> requestBackgroundFetchIfAvailable(call, result) "getData" -> getData(call, result) "getIntervalData" -> getIntervalData(call, result) "writeData" -> writeData(call, result) @@ -2485,6 +2495,40 @@ class HealthPlugin(private var channel: MethodChannel? = null) : } } + @OptIn(ExperimentalFeatureAvailabilityApi::class) + private fun requestBackgroundFetchIfAvailable(call: MethodCall, result: Result) { + if (healthConnectClient + .features + .getFeatureStatus( + HealthConnectFeatures.FEATURE_READ_HEALTH_DATA_IN_BACKGROUND + ) == HealthConnectFeatures.FEATURE_STATUS_AVAILABLE + ) { + scope.launch { + val grantedPermissions = + healthConnectClient.permissionController.getGrantedPermissions() + if (PERMISSION_READ_HEALTH_DATA_IN_BACKGROUND !in grantedPermissions) { + val requestPermissionActivityContract = + PermissionController.createRequestPermissionResultContract() + + val healthConnectRequestPermissionsLauncher = + (activity as ComponentActivity).registerForActivityResult( + requestPermissionActivityContract + ) { granted -> result.success(true) } + + healthConnectRequestPermissionsLauncher.launch( + setOf(PERMISSION_READ_HEALTH_DATA_IN_BACKGROUND) + ) + } else { + result.success(true) + } + } + + } + + result.success(false) + } + + private fun isForegroundServiceRunning(context: Context, serviceClass: Class<*>): Boolean { val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager for (service in activityManager.getRunningServices(Int.MAX_VALUE)) { @@ -2496,8 +2540,9 @@ class HealthPlugin(private var channel: MethodChannel? = null) : } private fun isStepSensorRunning(call: MethodCall, result: Result) { - result.success(isForegroundServiceRunning(context!!,StepCounterService::class.java)) + result.success(isForegroundServiceRunning(context!!, StepCounterService::class.java)) } + private fun clearStepSensorData(call: MethodCall, result: Result) { StepCounterService.box.boxFor(SensorStep::class.java).removeAll() result.success(true) @@ -2511,7 +2556,7 @@ class HealthPlugin(private var channel: MethodChannel? = null) : } val serviceIntent = Intent(activity!!, StepCounterService::class.java) val stopped = activity!!.stopService(serviceIntent) - + result.success(stopped) } catch (e: Exception) { Log.e("FLUTTER_HEALTH::ERROR", "Failed to stop step sensor service: ${e.message}") @@ -2542,7 +2587,7 @@ class HealthPlugin(private var channel: MethodChannel? = null) : try { val serviceIntent = Intent(activity!!, StepCounterService::class.java) ContextCompat.startForegroundService(context!!, serviceIntent) - } catch (e:Exception) { + } catch (e: Exception) { Log.d("HealthPlugin", e.toString()); } } @@ -2571,7 +2616,7 @@ class HealthPlugin(private var channel: MethodChannel? = null) : (activity as ComponentActivity).registerForActivityResult( ActivityResultContracts.RequestMultiplePermissions() ) { granted -> - if(granted.isNotEmpty() && granted.values.first()) { + if (granted.isNotEmpty() && granted.values.first()) { startStepSenor() mResult?.success(true) return@registerForActivityResult @@ -2799,7 +2844,7 @@ class HealthPlugin(private var channel: MethodChannel? = null) : val records = mutableListOf() val granted = healthConnectClient.permissionController.getGrantedPermissions() - if(!granted.contains(HealthPermission.getReadPermission(classType))) { + if (!granted.contains(HealthPermission.getReadPermission(classType))) { Log.d("Health Plugin", "No Permission granted for $dataType") return@let } From 5b76e183900305c91081e1a3a56606d04337a909 Mon Sep 17 00:00:00 2001 From: Arman Soudi Date: Mon, 11 Aug 2025 00:48:10 +0200 Subject: [PATCH 3/4] update plugin --- packages/health/android/build.gradle | 2 +- .../cachet/plugins/health/HealthPlugin.kt | 183 ++++++++++++++---- .../plugins/health/StepCounterService.kt | 5 + packages/health/lib/src/health_plugin.dart | 9 + 4 files changed, 159 insertions(+), 40 deletions(-) diff --git a/packages/health/android/build.gradle b/packages/health/android/build.gradle index 15e49fbec..09c6bfc8a 100644 --- a/packages/health/android/build.gradle +++ b/packages/health/android/build.gradle @@ -66,7 +66,7 @@ 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.1.0") + implementation("com.google.android.gms:play-services-fitness:21.3.0") implementation("com.google.android.gms:play-services-auth:20.2.0") // The new health connect api 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 0b7888395..cc3096f2c 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,6 +1,7 @@ package cachet.plugins.health import android.Manifest +import android.annotation.SuppressLint import android.app.Activity import android.app.ActivityManager import android.app.Service @@ -17,7 +18,6 @@ import androidx.activity.ComponentActivity import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContracts import androidx.annotation.NonNull -import androidx.annotation.RequiresApi import androidx.core.content.ContextCompat import androidx.health.connect.client.HealthConnectClient import androidx.health.connect.client.HealthConnectFeatures @@ -38,18 +38,24 @@ import androidx.health.connect.client.time.TimeRangeFilter import androidx.health.connect.client.units.* import com.google.android.gms.auth.api.signin.GoogleSignIn import com.google.android.gms.auth.api.signin.GoogleSignInOptions +import com.google.android.gms.common.ConnectionResult +import com.google.android.gms.common.GoogleApiAvailability import com.google.android.gms.fitness.Fitness import com.google.android.gms.fitness.FitnessActivities +import com.google.android.gms.fitness.FitnessLocal import com.google.android.gms.fitness.FitnessOptions +import com.google.android.gms.fitness.LocalRecordingClient 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.LocalDataReadRequest import com.google.android.gms.fitness.request.SessionInsertRequest import com.google.android.gms.fitness.request.SessionReadRequest import com.google.android.gms.fitness.result.DataReadResponse import com.google.android.gms.fitness.result.SessionReadResponse import com.google.android.gms.tasks.OnFailureListener import com.google.android.gms.tasks.OnSuccessListener +import com.google.android.gms.tasks.Tasks import io.flutter.embedding.engine.plugins.FlutterPlugin import io.flutter.embedding.engine.plugins.activity.ActivityAware import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding @@ -81,6 +87,7 @@ class HealthPlugin(private var channel: MethodChannel? = null) : private var context: Context? = null private var threadPoolExecutor: ExecutorService? = null private var useHealthConnectIfAvailable: Boolean = false + private var stepSensorActive: Boolean = false private var healthConnectRequestPermissionsLauncher: ActivityResultLauncher>? = null private var activityRecognitionPermissionLauncher: ActivityResultLauncher>? = @@ -1317,6 +1324,20 @@ class HealthPlugin(private var channel: MethodChannel? = null) : /** Get all datapoints of the DataType within the given time range */ private fun getData(call: MethodCall, result: Result) { + if (stepSensorActive) { + CoroutineScope(Dispatchers.IO).launch { + try { + fetchCacheData(10) + } catch (e: Exception) { + // ignore + } + withContext(Dispatchers.Main) { + getSensorData(call, result) + } + } + return + } + if (StepCounterService.initiated()) { getSensorData(call, result) return @@ -1479,7 +1500,7 @@ class HealthPlugin(private var channel: MethodChannel? = null) : val healthConnectData = mutableListOf>() - if (dataType == STEPS && StepCounterService.initiated()) { + if (dataType == STEPS) { val items = StepCounterService.box.boxFor(SensorStep::class.java).query( SensorStep_.startTime.between( startTime.toEpochMilli(), @@ -2306,10 +2327,87 @@ class HealthPlugin(private var channel: MethodChannel? = null) : } } + private fun lastSyncTime(): ZonedDateTime { + return Instant.ofEpochSecond( + activity!!.getSharedPreferences("my_prefs", Context.MODE_PRIVATE) + .getLong( + "last_q_epoch", + LocalDateTime.now().atZone(ZoneId.systemDefault()).minusHours(1).toEpochSecond() + ) + ).atZone(ZoneId.systemDefault()) + } + + private fun updateLastSyncTime(atZone: ZonedDateTime) { + activity!!.getSharedPreferences("my_prefs", Context.MODE_PRIVATE) + .edit() + .putLong("last_q_epoch", atZone.toEpochSecond()) + .apply() + } + + private suspend fun fetchCacheData(duration: Int = 10) { + + val queryEndTime = LocalDateTime.now().atZone(ZoneId.systemDefault()) + val queryStartTime = lastSyncTime() + val readRequest = + LocalDataReadRequest.Builder() + .aggregate(LocalDataType.TYPE_STEP_COUNT_DELTA) + .bucketByTime(duration, TimeUnit.SECONDS) + .setTimeRange( + queryStartTime.toEpochSecond(), + queryEndTime.toEpochSecond(), + TimeUnit.SECONDS + ) + .build() + val localRecordingClient = FitnessLocal.getLocalRecordingClient(activity!!) + val result = Tasks.await(localRecordingClient.readData(readRequest)) + val data = result.buckets.flatMap { bucket -> bucket.dataSets.flatMap { it.dataPoints } } + val sensorStepData = data.map { each -> + SensorStep().apply { + startTime = each.getStartTime(TimeUnit.MILLISECONDS) + endTime = each.getEndTime(TimeUnit.MILLISECONDS) + count = each.getValue(LocalField.FIELD_STEPS).asInt().toDouble() + } + } + if (sensorStepData.isNotEmpty()) { + updateLastSyncTime(LocalDateTime.now().atZone(ZoneId.systemDefault())) + Log.d( + "HealthPlugin", + "Sensor step data received: ${sensorStepData.size} records. Updating query and storing data." + ) + + StepCounterService.box.boxFor(SensorStep::class.java).put(sensorStepData) + } else { + Log.d( + "HealthPlugin", + "No sensor step data found. Extending query duration." + ) + } + } + private fun getTotalStepsInInterval(call: MethodCall, result: Result) { val start = call.argument("startTime")!! val end = call.argument("endTime")!! + if (stepSensorActive) { + + if (!StepCounterService.initiated()) { + StepCounterService.box = MyObjectBox.builder() + .androidContext(context) + .build(); + } + + CoroutineScope(Dispatchers.IO).launch { + try { + fetchCacheData(10) + } catch (e: Exception) { + // ignore + } + withContext(Dispatchers.Main) { + getSensorDataCount(call, result) + } + } + return + } if (StepCounterService.initiated()) { getSensorDataCount(call, result) @@ -2503,26 +2601,11 @@ class HealthPlugin(private var channel: MethodChannel? = null) : HealthConnectFeatures.FEATURE_READ_HEALTH_DATA_IN_BACKGROUND ) == HealthConnectFeatures.FEATURE_STATUS_AVAILABLE ) { - scope.launch { - val grantedPermissions = - healthConnectClient.permissionController.getGrantedPermissions() - if (PERMISSION_READ_HEALTH_DATA_IN_BACKGROUND !in grantedPermissions) { - val requestPermissionActivityContract = - PermissionController.createRequestPermissionResultContract() - - val healthConnectRequestPermissionsLauncher = - (activity as ComponentActivity).registerForActivityResult( - requestPermissionActivityContract - ) { granted -> result.success(true) } - - healthConnectRequestPermissionsLauncher.launch( - setOf(PERMISSION_READ_HEALTH_DATA_IN_BACKGROUND) - ) - } else { - result.success(true) - } - } - + mResult = result + healthConnectRequestPermissionsLauncher!!.launch( + setOf(PERMISSION_READ_HEALTH_DATA_IN_BACKGROUND) + ) + return } result.success(false) @@ -2540,7 +2623,12 @@ class HealthPlugin(private var channel: MethodChannel? = null) : } private fun isStepSensorRunning(call: MethodCall, result: Result) { - result.success(isForegroundServiceRunning(context!!, StepCounterService::class.java)) + result.success( + isForegroundServiceRunning( + context!!, + StepCounterService::class.java + ) || stepSensorActive + ) } private fun clearStepSensorData(call: MethodCall, result: Result) { @@ -2566,27 +2654,44 @@ class HealthPlugin(private var channel: MethodChannel? = null) : } private fun startStepSensorBackgroundService(call: MethodCall, result: Result) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - if (ContextCompat.checkSelfPermission( - activity!!, - Manifest.permission.ACTIVITY_RECOGNITION - ) - != PackageManager.PERMISSION_GRANTED - ) { - mResult = result - activityRecognitionPermissionLauncher!!.launch(arrayOf(Manifest.permission.ACTIVITY_RECOGNITION)) - return - } + if (ContextCompat.checkSelfPermission( + activity!!, + Manifest.permission.ACTIVITY_RECOGNITION + ) + != PackageManager.PERMISSION_GRANTED + ) { + mResult = result + activityRecognitionPermissionLauncher!!.launch(arrayOf(Manifest.permission.ACTIVITY_RECOGNITION)) + return + } else { + startStepSenor() + result.success(true) } - - startStepSenor() - result.success(true) } + @SuppressLint("MissingPermission") private fun startStepSenor() { try { - val serviceIntent = Intent(activity!!, StepCounterService::class.java) - ContextCompat.startForegroundService(context!!, serviceIntent) + val hasMinPlayServices = GoogleApiAvailability.getInstance() + .isGooglePlayServicesAvailable( + context!!, + LocalRecordingClient.LOCAL_RECORDING_CLIENT_MIN_VERSION_CODE + ) + if (hasMinPlayServices == ConnectionResult.SUCCESS) { + val localRecordingClient = FitnessLocal.getLocalRecordingClient(activity!!) + localRecordingClient.subscribe(LocalDataType.TYPE_STEP_COUNT_DELTA) + .addOnSuccessListener { + stepSensorActive = true + Log.d("HealthPlugin", "FitnessLocal subscribed"); + } + .addOnFailureListener { e -> + Log.e("HealthPlugin", "FitnessLocal subscription failed: $e"); + } + + } else { + val serviceIntent = Intent(activity!!, StepCounterService::class.java) + ContextCompat.startForegroundService(context!!, serviceIntent) + } } catch (e: Exception) { Log.d("HealthPlugin", e.toString()); } diff --git a/packages/health/android/src/main/kotlin/cachet/plugins/health/StepCounterService.kt b/packages/health/android/src/main/kotlin/cachet/plugins/health/StepCounterService.kt index 380eec897..e3295097f 100644 --- a/packages/health/android/src/main/kotlin/cachet/plugins/health/StepCounterService.kt +++ b/packages/health/android/src/main/kotlin/cachet/plugins/health/StepCounterService.kt @@ -16,6 +16,11 @@ import android.os.SystemClock import android.util.Log import androidx.core.app.NotificationCompat import androidx.core.app.ServiceCompat +import com.google.android.gms.common.ConnectionResult +import com.google.android.gms.common.GoogleApiAvailability +import com.google.android.gms.fitness.FitnessLocal +import com.google.android.gms.fitness.LocalRecordingClient +import com.google.android.gms.fitness.data.LocalDataType import io.objectbox.BoxStore diff --git a/packages/health/lib/src/health_plugin.dart b/packages/health/lib/src/health_plugin.dart index d22c0cd58..586e1c608 100644 --- a/packages/health/lib/src/health_plugin.dart +++ b/packages/health/lib/src/health_plugin.dart @@ -140,6 +140,15 @@ class Health { } } + Future requestBackgroundFetchIfAvailable() async { + try { + await _channel.invokeMethod('requestBackgroundFetchIfAvailable'); + return; + } catch (e) { + debugPrint('$runtimeType - Exception in revokePermissions(): $e'); + } + } + /// Returns the current status of Health Connect availability. /// /// See this for more info: From ab8f7e362f5d15359bdb26c87a30031656b2c2dd Mon Sep 17 00:00:00 2001 From: Arman Soudi Date: Thu, 11 Sep 2025 22:31:55 +0200 Subject: [PATCH 4/4] make it compatible with latest flutter version --- .../main/kotlin/cachet/plugins/health/HealthPlugin.kt | 10 ---------- 1 file changed, 10 deletions(-) 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 cc3096f2c..5c92d85db 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 @@ -64,7 +64,6 @@ import io.flutter.plugin.common.MethodChannel 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 java.time.* import java.time.temporal.ChronoUnit import java.util.* @@ -527,15 +526,6 @@ class HealthPlugin(private var channel: MethodChannel? = null) : // 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) - } - const val PERMISSIONS_REQUEST_ACTIVITY_RECOGNITION = 10 }