From 017a177f793c15658120510dc2185fe3df62e5e4 Mon Sep 17 00:00:00 2001 From: mirland Date: Tue, 9 Dec 2025 11:05:13 -0300 Subject: [PATCH] fix: Fix ANRs, Refactor HealthData operations to use multiple dispatchers --- .../plugins/health/HealthDataOperations.kt | 91 +++++++----- .../cachet/plugins/health/HealthDataReader.kt | 129 +++++++++++------- .../cachet/plugins/health/HealthDataWriter.kt | 46 +++++-- .../cachet/plugins/health/HealthPlugin.kt | 4 +- 4 files changed, 169 insertions(+), 101 deletions(-) diff --git a/android/src/main/kotlin/cachet/plugins/health/HealthDataOperations.kt b/android/src/main/kotlin/cachet/plugins/health/HealthDataOperations.kt index 78561516..e01191a6 100644 --- a/android/src/main/kotlin/cachet/plugins/health/HealthDataOperations.kt +++ b/android/src/main/kotlin/cachet/plugins/health/HealthDataOperations.kt @@ -10,8 +10,11 @@ import androidx.health.connect.client.time.TimeRangeFilter import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel.Result import java.time.Instant +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext /** * Handles Health Connect operational tasks including permissions, SDK status, and data deletion @@ -21,7 +24,8 @@ class HealthDataOperations( private val healthConnectClient: HealthConnectClient, private val scope: CoroutineScope, private val healthConnectStatus: Int, - private val healthConnectAvailable: Boolean + private val healthConnectAvailable: Boolean, + private val mainDispatcher: CoroutineDispatcher = Dispatchers.Main ) { /** @@ -54,12 +58,13 @@ class HealthDataOperations( } scope.launch { - result.success( - healthConnectClient - .permissionController - .getGrantedPermissions() - .containsAll(permList), - ) + val hasPermissions = healthConnectClient + .permissionController + .getGrantedPermissions() + .containsAll(permList) + withContext(mainDispatcher) { + result.success(hasPermissions) + } } } @@ -104,11 +109,12 @@ class HealthDataOperations( */ fun isHealthDataHistoryAvailable(call: MethodCall, result: Result) { scope.launch { - result.success( - healthConnectClient.features.getFeatureStatus( - HealthConnectFeatures.FEATURE_READ_HEALTH_DATA_HISTORY - ) == HealthConnectFeatures.FEATURE_STATUS_AVAILABLE - ) + val isAvailable = healthConnectClient.features.getFeatureStatus( + HealthConnectFeatures.FEATURE_READ_HEALTH_DATA_HISTORY + ) == HealthConnectFeatures.FEATURE_STATUS_AVAILABLE + withContext(mainDispatcher) { + result.success(isAvailable) + } } } @@ -121,12 +127,13 @@ class HealthDataOperations( */ fun isHealthDataHistoryAuthorized(call: MethodCall, result: Result) { scope.launch { - result.success( - healthConnectClient - .permissionController - .getGrantedPermissions() - .containsAll(listOf(PERMISSION_READ_HEALTH_DATA_HISTORY)), - ) + val isAuthorized = healthConnectClient + .permissionController + .getGrantedPermissions() + .containsAll(listOf(PERMISSION_READ_HEALTH_DATA_HISTORY)) + withContext(mainDispatcher) { + result.success(isAuthorized) + } } } @@ -139,11 +146,12 @@ class HealthDataOperations( */ fun isHealthDataInBackgroundAvailable(call: MethodCall, result: Result) { scope.launch { - result.success( - healthConnectClient.features.getFeatureStatus( - HealthConnectFeatures.FEATURE_READ_HEALTH_DATA_IN_BACKGROUND - ) == HealthConnectFeatures.FEATURE_STATUS_AVAILABLE - ) + val isAvailable = healthConnectClient.features.getFeatureStatus( + HealthConnectFeatures.FEATURE_READ_HEALTH_DATA_IN_BACKGROUND + ) == HealthConnectFeatures.FEATURE_STATUS_AVAILABLE + withContext(mainDispatcher) { + result.success(isAvailable) + } } } @@ -156,12 +164,13 @@ class HealthDataOperations( */ fun isHealthDataInBackgroundAuthorized(call: MethodCall, result: Result) { scope.launch { - result.success( - healthConnectClient - .permissionController - .getGrantedPermissions() - .containsAll(listOf(PERMISSION_READ_HEALTH_DATA_IN_BACKGROUND)), - ) + val isAuthorized = healthConnectClient + .permissionController + .getGrantedPermissions() + .containsAll(listOf(PERMISSION_READ_HEALTH_DATA_IN_BACKGROUND)) + withContext(mainDispatcher) { + result.success(isAuthorized) + } } } @@ -191,14 +200,18 @@ class HealthDataOperations( recordType = classType, timeRangeFilter = TimeRangeFilter.between(startTime, endTime), ) - result.success(true) Log.i( "FLUTTER_HEALTH::SUCCESS", "Successfully deleted $type records between $startTime and $endTime" ) + withContext(mainDispatcher) { + result.success(true) + } } catch (e: Exception) { Log.e("FLUTTER_HEALTH::ERROR", "Error deleting $type records: ${e.message}") - result.success(false) + withContext(mainDispatcher) { + result.success(false) + } } } } @@ -230,16 +243,20 @@ class HealthDataOperations( recordIdsList = listOf(uuid), clientRecordIdsList = emptyList() ) - result.success(true) Log.i( "FLUTTER_HEALTH::SUCCESS", "[Health Connect] Record with UUID $uuid was successfully deleted!" ) + withContext(mainDispatcher) { + result.success(true) + } } catch (e: Exception) { Log.e("FLUTTER_HEALTH::ERROR", "Error deleting record with UUID: $uuid") Log.e("FLUTTER_HEALTH::ERROR", e.message ?: "unknown error") Log.e("FLUTTER_HEALTH::ERROR", e.stackTraceToString()) - result.success(false) + withContext(mainDispatcher) { + result.success(false) + } } } } @@ -270,7 +287,9 @@ class HealthDataOperations( recordId, clientRecordId ) - result.success(true) + withContext(mainDispatcher) { + result.success(true) + } } catch (e: Exception) { Log.e( "FLUTTER_HEALTH::ERROR", @@ -278,7 +297,9 @@ class HealthDataOperations( ) Log.e("FLUTTER_HEALTH::ERROR", e.message ?: "unknown error") Log.e("FLUTTER_HEALTH::ERROR", e.stackTraceToString()) - result.success(false) + withContext(mainDispatcher) { + result.success(false) + } } } } diff --git a/android/src/main/kotlin/cachet/plugins/health/HealthDataReader.kt b/android/src/main/kotlin/cachet/plugins/health/HealthDataReader.kt index 37eab383..b3948afd 100644 --- a/android/src/main/kotlin/cachet/plugins/health/HealthDataReader.kt +++ b/android/src/main/kotlin/cachet/plugins/health/HealthDataReader.kt @@ -1,7 +1,5 @@ package cachet.plugins.health -import android.content.Context -import android.os.Handler import android.util.Log import androidx.health.connect.client.HealthConnectClient import androidx.health.connect.client.permission.HealthPermission @@ -13,8 +11,13 @@ import androidx.health.connect.client.time.TimeRangeFilter import androidx.health.connect.client.units.* import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel.Result +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import java.time.Duration import java.time.Instant import java.time.temporal.ChronoUnit @@ -26,8 +29,8 @@ import java.time.temporal.ChronoUnit class HealthDataReader( private val healthConnectClient: HealthConnectClient, private val scope: CoroutineScope, - private val context: Context, - private val dataConverter: HealthDataConverter + private val dataConverter: HealthDataConverter, + private val mainDispatcher: CoroutineDispatcher = Dispatchers.Main ) { private val recordingFilter = HealthRecordingFilter() @@ -107,14 +110,18 @@ class HealthDataReader( } } } - Handler(context.mainLooper).run { result.success(healthConnectData) } + withContext(mainDispatcher) { + result.success(healthConnectData) + } } catch (e: Exception) { Log.i( "FLUTTER_HEALTH::ERROR", "Unable to return $dataType due to the following exception:" ) Log.e("FLUTTER_HEALTH::ERROR", Log.getStackTraceString(e)) - result.success(emptyList>()) // Return empty list instead of null + withContext(mainDispatcher) { + result.success(emptyList>()) + } } } } @@ -175,16 +182,22 @@ class HealthDataReader( "Success: $healthPoint" ) - Handler(context.mainLooper).run { result.success(healthPoint) } + withContext(mainDispatcher) { + result.success(healthPoint) + } } else { Log.e("FLUTTER_HEALTH::ERROR", "Record not found for UUID: $uuid") - result.success(null) + withContext(mainDispatcher) { + result.success(null) + } } } catch (e: Exception) { Log.e("FLUTTER_HEALTH::ERROR", "Error fetching record with UUID: $uuid") Log.e("FLUTTER_HEALTH::ERROR", e.message ?: "unknown error") Log.e("FLUTTER_HEALTH::ERROR", e.stackTraceToString()) - result.success(null) + withContext(mainDispatcher) { + result.success(null) + } } } } @@ -235,14 +248,18 @@ class HealthDataReader( healthConnectData.add(data) } } - Handler(context.mainLooper).run { result.success(healthConnectData) } + withContext(mainDispatcher) { + result.success(healthConnectData) + } } catch (e: Exception) { Log.i( "FLUTTER_HEALTH::ERROR", "Unable to return $dataType due to the following exception:" ) Log.e("FLUTTER_HEALTH::ERROR", Log.getStackTraceString(e)) - result.success(null) + withContext(mainDispatcher) { + result.success(null) + } } } } @@ -303,14 +320,18 @@ class HealthDataReader( val stepsInInterval = response[StepsRecord.COUNT_TOTAL] ?: 0L Log.i("FLUTTER_HEALTH::SUCCESS", "returning $stepsInInterval steps") - result.success(stepsInInterval) + withContext(mainDispatcher) { + result.success(stepsInInterval) + } } catch (e: Exception) { Log.e( "FLUTTER_HEALTH::ERROR", "Unable to return steps due to the following exception:" ) Log.e("FLUTTER_HEALTH::ERROR", Log.getStackTraceString(e)) - result.success(null) + withContext(mainDispatcher) { + result.success(null) + } } } } @@ -350,14 +371,18 @@ class HealthDataReader( "FLUTTER_HEALTH::SUCCESS", "returning $totalSteps steps (excluding manual entries)" ) - result.success(totalSteps) + withContext(mainDispatcher) { + result.success(totalSteps) + } } catch (e: Exception) { Log.e( "FLUTTER_HEALTH::ERROR", "Unable to return steps due to the following exception:" ) Log.e("FLUTTER_HEALTH::ERROR", Log.getStackTraceString(e)) - result.success(null) + withContext(mainDispatcher) { + result.success(null) + } } } } @@ -388,50 +413,48 @@ class HealthDataReader( for (rec in filteredRecords) { val record = rec as ExerciseSessionRecord - // Get distance data - val distanceRequest = healthConnectClient.readRecords( - ReadRecordsRequest( - recordType = DistanceRecord::class, - timeRangeFilter = TimeRangeFilter.between( - record.startTime, - record.endTime, + val distanceDeferred = scope.async { + val response = 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 + ) + response.records.sumOf { it.distance.inMeters } } - - // Get energy burned data - val energyBurnedRequest = healthConnectClient.readRecords( - ReadRecordsRequest( - recordType = TotalCaloriesBurnedRecord::class, - timeRangeFilter = TimeRangeFilter.between( - record.startTime, - record.endTime, + + val energyDeferred = scope.async { + val response = 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 + ) + response.records.sumOf { it.energy.inKilocalories } } - - // Get steps data - val stepRequest = healthConnectClient.readRecords( - ReadRecordsRequest( - recordType = StepsRecord::class, - timeRangeFilter = TimeRangeFilter.between( - record.startTime, - record.endTime + + val stepsDeferred = scope.async { + val response = healthConnectClient.readRecords( + ReadRecordsRequest( + recordType = StepsRecord::class, + timeRangeFilter = TimeRangeFilter.between( + record.startTime, + record.endTime + ), ), - ), - ) - var totalSteps = 0.0 - for (stepRec in stepRequest.records) { - totalSteps += stepRec.count + ) + response.records.sumOf { it.count.toDouble() } } + + val totalDistance = distanceDeferred.await() + val totalEnergyBurned = energyDeferred.await() + val totalSteps = stepsDeferred.await() // Add final datapoint healthConnectData.add( diff --git a/android/src/main/kotlin/cachet/plugins/health/HealthDataWriter.kt b/android/src/main/kotlin/cachet/plugins/health/HealthDataWriter.kt index 976a9bf1..a2f2ae32 100644 --- a/android/src/main/kotlin/cachet/plugins/health/HealthDataWriter.kt +++ b/android/src/main/kotlin/cachet/plugins/health/HealthDataWriter.kt @@ -9,8 +9,11 @@ import androidx.health.connect.client.units.* import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel.Result import java.time.Instant +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext /** * Handles writing health data to Health Connect. Manages data insertion for various health metrics, @@ -19,7 +22,8 @@ import kotlinx.coroutines.launch */ class HealthDataWriter( private val healthConnectClient: HealthConnectClient, - private val scope: CoroutineScope + private val scope: CoroutineScope, + private val mainDispatcher: CoroutineDispatcher = Dispatchers.Main ) { // Maps incoming recordingMethod int -> Metadata factory method. @@ -130,10 +134,14 @@ class HealthDataWriter( scope.launch { try { healthConnectClient.insertRecords(listOf(record)) - result.success(true) + withContext(mainDispatcher) { + result.success(true) + } } catch (e: Exception) { Log.e("FLUTTER_HEALTH::ERROR", "Error writing $type: ${e.message}") - result.success(false) + withContext(mainDispatcher) { + result.success(false) + } } } } @@ -215,8 +223,10 @@ class HealthDataWriter( } healthConnectClient.insertRecords(list) - result.success(true) Log.i("FLUTTER_HEALTH::SUCCESS", "[Health Connect] Workout was successfully added!") + withContext(mainDispatcher) { + result.success(true) + } } catch (e: Exception) { Log.w( "FLUTTER_HEALTH::ERROR", @@ -224,7 +234,9 @@ class HealthDataWriter( ) Log.w("FLUTTER_HEALTH::ERROR", e.message ?: "unknown error") Log.w("FLUTTER_HEALTH::ERROR", e.stackTrace.toString()) - result.success(false) + withContext(mainDispatcher) { + result.success(false) + } } } } @@ -264,11 +276,13 @@ class HealthDataWriter( ), ), ) - result.success(true) Log.i( "FLUTTER_HEALTH::SUCCESS", "[Health Connect] Blood pressure was successfully added!", ) + withContext(mainDispatcher) { + result.success(true) + } } catch (e: Exception) { Log.w( "FLUTTER_HEALTH::ERROR", @@ -276,7 +290,9 @@ class HealthDataWriter( ) Log.w("FLUTTER_HEALTH::ERROR", e.message ?: "unknown error") Log.w("FLUTTER_HEALTH::ERROR", e.stackTrace.toString()) - result.success(false) + withContext(mainDispatcher) { + result.success(false) + } } } } @@ -429,8 +445,10 @@ class HealthDataWriter( ), ) healthConnectClient.insertRecords(list) - result.success(true) Log.i("FLUTTER_HEALTH::SUCCESS", "[Health Connect] Meal was successfully added!") + withContext(mainDispatcher) { + result.success(true) + } } catch (e: Exception) { Log.w( "FLUTTER_HEALTH::ERROR", @@ -438,7 +456,9 @@ class HealthDataWriter( ) Log.w("FLUTTER_HEALTH::ERROR", e.message ?: "unknown error") Log.w("FLUTTER_HEALTH::ERROR", e.stackTrace.toString()) - result.success(false) + withContext(mainDispatcher) { + result.success(false) + } } } } @@ -488,14 +508,18 @@ class HealthDataWriter( ) healthConnectClient.insertRecords(listOf(speedRecord)) - result.success(true) Log.i( "FLUTTER_HEALTH::SUCCESS", "Successfully wrote ${speedSamples.size} speed samples" ) + withContext(mainDispatcher) { + result.success(true) + } } catch (e: Exception) { Log.e("FLUTTER_HEALTH::ERROR", "Error writing speed data: ${e.message}") - result.success(false) + withContext(mainDispatcher) { + result.success(false) + } } } } diff --git a/android/src/main/kotlin/cachet/plugins/health/HealthPlugin.kt b/android/src/main/kotlin/cachet/plugins/health/HealthPlugin.kt index 888240ee..e228be66 100644 --- a/android/src/main/kotlin/cachet/plugins/health/HealthPlugin.kt +++ b/android/src/main/kotlin/cachet/plugins/health/HealthPlugin.kt @@ -61,7 +61,7 @@ class HealthPlugin(private var channel: MethodChannel? = null) : override fun onAttachedToEngine( @NonNull flutterPluginBinding: FlutterPlugin.FlutterPluginBinding ) { - scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) + scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) channel = MethodChannel(flutterPluginBinding.binaryMessenger, CHANNEL_NAME) channel?.setMethodCallHandler(this) context = flutterPluginBinding.applicationContext @@ -231,7 +231,7 @@ class HealthPlugin(private var channel: MethodChannel? = null) : */ private fun initializeHelpers() { dataConverter = HealthDataConverter() - dataReader = HealthDataReader(healthConnectClient, scope, context!!, dataConverter) + dataReader = HealthDataReader(healthConnectClient, scope, dataConverter) dataWriter = HealthDataWriter(healthConnectClient, scope) dataOperations = HealthDataOperations(