package expo.modules.healthkitmodule import android.content.Context import android.content.Intent import android.content.pm.PackageManager import android.net.Uri import android.provider.Settings import expo.modules.kotlin.modules.Module import expo.modules.kotlin.modules.ModuleDefinition import expo.modules.kotlin.Promise import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import java.net.URL import java.time.Instant import java.time.format.DateTimeFormatter import kotlin.reflect.KClass import androidx.health.connect.client.HealthConnectClient import androidx.health.connect.client.permission.HealthPermission import androidx.health.connect.client.PermissionController import androidx.health.connect.client.records.* import androidx.health.connect.client.request.ReadRecordsRequest import androidx.health.connect.client.time.TimeRangeFilter class ExpoHealthkitModule : Module() { private val context: Context get() = appContext.reactContext ?: throw Exception("React context is not available") private val healthConnectClient by lazy { HealthConnectClient.getOrCreate(context) } private val moduleScope = CoroutineScope(Dispatchers.Main) // Store pending permission promise to resolve when user returns from Health Connect private var pendingPermissionPromise: Promise? = null private var requestedPermissionsSet: Set? = null // Health data types mapping private val recordTypeMap = mapOf( "ActiveCaloriesBurned" to Pair(ActiveCaloriesBurnedRecord::class, HealthPermission.getReadPermission(ActiveCaloriesBurnedRecord::class)), "BasalMetabolicRate" to Pair(BasalMetabolicRateRecord::class, HealthPermission.getReadPermission(BasalMetabolicRateRecord::class)), "BodyFat" to Pair(BodyFatRecord::class, HealthPermission.getReadPermission(BodyFatRecord::class)), "CyclingPedalingCadence" to Pair(CyclingPedalingCadenceRecord::class, HealthPermission.getReadPermission(CyclingPedalingCadenceRecord::class)), "Distance" to Pair(DistanceRecord::class, HealthPermission.getReadPermission(DistanceRecord::class)), "ElevationGained" to Pair(ElevationGainedRecord::class, HealthPermission.getReadPermission(ElevationGainedRecord::class)), "ExerciseSession" to Pair(ExerciseSessionRecord::class, HealthPermission.getReadPermission(ExerciseSessionRecord::class)), "FloorsClimbed" to Pair(FloorsClimbedRecord::class, HealthPermission.getReadPermission(FloorsClimbedRecord::class)), "HeartRate" to Pair(HeartRateRecord::class, HealthPermission.getReadPermission(HeartRateRecord::class)), "LeanBodyMass" to Pair(LeanBodyMassRecord::class, HealthPermission.getReadPermission(LeanBodyMassRecord::class)), "OxygenSaturation" to Pair(OxygenSaturationRecord::class, HealthPermission.getReadPermission(OxygenSaturationRecord::class)), "RespiratoryRate" to Pair(RespiratoryRateRecord::class, HealthPermission.getReadPermission(RespiratoryRateRecord::class)), "RestingHeartRate" to Pair(RestingHeartRateRecord::class, HealthPermission.getReadPermission(RestingHeartRateRecord::class)), "SleepSession" to Pair(SleepSessionRecord::class, HealthPermission.getReadPermission(SleepSessionRecord::class)), "Speed" to Pair(SpeedRecord::class, HealthPermission.getReadPermission(SpeedRecord::class)), "StepsCadence" to Pair(StepsCadenceRecord::class, HealthPermission.getReadPermission(StepsCadenceRecord::class)), "Steps" to Pair(StepsRecord::class, HealthPermission.getReadPermission(StepsRecord::class)), "TotalCaloriesBurned" to Pair(TotalCaloriesBurnedRecord::class, HealthPermission.getReadPermission(TotalCaloriesBurnedRecord::class)), "Vo2Max" to Pair(Vo2MaxRecord::class, HealthPermission.getReadPermission(Vo2MaxRecord::class)), "HeartRateVariability" to Pair(HeartRateVariabilityRmssdRecord::class, HealthPermission.getReadPermission(HeartRateVariabilityRmssdRecord::class)) ) private fun isHealthConnectAvailable(): Boolean { return try { // Android 14+ provides Health Connect as a system service. Use SDK status API. val status = HealthConnectClient.getSdkStatus(context) if (status == HealthConnectClient.SDK_AVAILABLE) { true } else { // Fallback for Android 13 and below – check if the Play Store app is installed. val packageManager = context.packageManager packageManager.getPackageInfo("com.google.android.apps.healthdata", 0) true } } catch (_: Exception) { false } } private fun formatRecordToStandardFormat(record: Record): Map { val formatter = DateTimeFormatter.ISO_INSTANT fun extractInstant(fieldName: String): Instant? = try { val field = record.javaClass.getDeclaredField(fieldName).apply { isAccessible = true } field.get(record) as? Instant } catch (e: Exception) { null } val startInstant = extractInstant("startTime") ?: extractInstant("time") ?: Instant.now() val endInstant = extractInstant("endTime") ?: startInstant return mapOf( "type" to record::class.simpleName, "sourceName" to (record.metadata.dataOrigin.packageName ?: "Unknown"), "sourceVersion" to null, "device" to (record.metadata.device?.model ?: record.metadata.device?.manufacturer), "creationDate" to formatter.format(endInstant), "startDate" to formatter.format(startInstant), "endDate" to formatter.format(endInstant), "metadata" to emptyMap(), "unit" to getUnitForRecord(record), "value" to getValueForRecord(record) ) } private fun getUnitForRecord(record: Record): String { return when (record) { is ActiveCaloriesBurnedRecord -> "kcal" is BasalMetabolicRateRecord -> "kcal/day" is BodyFatRecord -> "%" is CyclingPedalingCadenceRecord -> "rpm" is DistanceRecord -> "m" is ElevationGainedRecord -> "m" is FloorsClimbedRecord -> "count" is HeartRateRecord -> "bpm" is LeanBodyMassRecord -> "kg" is OxygenSaturationRecord -> "%" is RespiratoryRateRecord -> "breaths/min" is RestingHeartRateRecord -> "bpm" is SpeedRecord -> "m/s" is StepsCadenceRecord -> "steps/min" is StepsRecord -> "count" is TotalCaloriesBurnedRecord -> "kcal" is Vo2MaxRecord -> "mL/(kgΒ·min)" is HeartRateVariabilityRmssdRecord -> "ms" // HRV RMSSD unit is milliseconds is ExerciseSessionRecord -> "session" is SleepSessionRecord -> "session" else -> "unknown" } } private fun extractNumericValue(record: Record, primaryField: String, nestedListField: String? = null, nestedValueField: String? = null): Double? { return try { val clazz = record.javaClass if (nestedListField != null && nestedValueField != null) { val listField = clazz.getDeclaredField(nestedListField).apply { isAccessible = true } val list = listField.get(record) as? List<*> val nums = list?.mapNotNull { sample -> try { val valueField = sample!!::class.java.getDeclaredField(nestedValueField).apply { isAccessible = true } (valueField.get(sample) as? Number)?.toDouble() } catch (_: Exception) { null } } nums?.average() } else { val field = clazz.getDeclaredField(primaryField).apply { isAccessible = true } (field.get(record) as? Number)?.toDouble() } } catch (_: Exception) { null } } private fun getValueForRecord(record: Record): Any { return when (record) { is ActiveCaloriesBurnedRecord -> record.energy.inKilocalories is BasalMetabolicRateRecord -> record.basalMetabolicRate.inKilocaloriesPerDay is BodyFatRecord -> record.percentage.value is DistanceRecord -> record.distance.inMeters is ElevationGainedRecord -> record.elevation.inMeters is FloorsClimbedRecord -> record.floors.toDouble() is StepsRecord -> record.count.toDouble() is TotalCaloriesBurnedRecord -> record.energy.inKilocalories is HeartRateRecord -> extractNumericValue(record, "", "samples", "beatsPerMinute") ?: "unsupported" is RestingHeartRateRecord -> extractNumericValue(record, "", "samples", "beatsPerMinute") ?: "unsupported" is CyclingPedalingCadenceRecord -> extractNumericValue(record, "", "samples", "revolutionsPerMinute") ?: "unsupported" is RespiratoryRateRecord -> extractNumericValue(record, "", "samples", "rate") ?: "unsupported" is SpeedRecord -> extractNumericValue(record, "", "samples", "speedMetersPerSecond") ?: "unsupported" is StepsCadenceRecord -> extractNumericValue(record, "", "samples", "rate") ?: "unsupported" is Vo2MaxRecord -> extractNumericValue(record, "vo2MillilitersPerMinuteKilogram") ?: "unsupported" is HeartRateVariabilityRmssdRecord -> record.heartRateVariabilityMillis // HRV (RMSSD) value, in milliseconds is ExerciseSessionRecord -> record.exerciseType is SleepSessionRecord -> record.stages.size else -> "unsupported" } } private fun buildPermissionsIntent(ctx: Context, permissions: Set): Intent { return try { // Try new API (Android 14+, Health Connect SDK 1.2.0+) val method = PermissionController::class.java.getMethod( "createRequestPermissionIntent", Context::class.java, Set::class.java ) method.invoke(null, ctx, permissions) as Intent } catch (e: NoSuchMethodException) { // Fallback to Activity-Result contract (Android 13-) val contract = PermissionController.createRequestPermissionResultContract() contract.createIntent(ctx, permissions) } } private fun openHealthConnectSettings(activity: android.app.Activity): Boolean { return try { // Try multiple methods to open Health Connect settings // Package names: Android 14+ = com.google.android.healthconnect.controller // Android 13- = com.google.android.apps.healthdata // Method 1: Try Android 14+ Health Connect (system app) try { val launchIntent = context.packageManager.getLaunchIntentForPackage("com.google.android.healthconnect.controller") if (launchIntent != null) { launchIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) activity.startActivity(launchIntent) android.util.Log.d("HealthConnect", "🟒 Opened Health Connect app (Android 14+)") return true } } catch (e: Exception) { android.util.Log.d("HealthConnect", "⚠️ Method 1 (Android 14+) failed: ${e.message}") } // Method 2: Try Android 13- Health Connect (Play Store app) try { val launchIntent = context.packageManager.getLaunchIntentForPackage("com.google.android.apps.healthdata") if (launchIntent != null) { launchIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) activity.startActivity(launchIntent) android.util.Log.d("HealthConnect", "🟒 Opened Health Connect app (Android 13-)") return true } } catch (e: Exception) { android.util.Log.d("HealthConnect", "⚠️ Method 2 (Android 13-) failed: ${e.message}") } // Method 3: Try app settings for Android 14+ try { val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) intent.data = Uri.parse("package:com.google.android.healthconnect.controller") intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) activity.startActivity(intent) android.util.Log.d("HealthConnect", "🟒 Opened Health Connect app settings (Android 14+)") return true } catch (e: Exception) { android.util.Log.d("HealthConnect", "⚠️ Method 3 (app settings) failed: ${e.message}") } // Method 4: Try system health settings intent try { val intent = Intent() intent.action = "android.settings.HEALTH_CONNECT_SETTINGS" intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) activity.startActivity(intent) android.util.Log.d("HealthConnect", "🟒 Opened Health Connect system settings") return true } catch (e: Exception) { android.util.Log.d("HealthConnect", "⚠️ Method 4 (system settings intent) failed: ${e.message}") } android.util.Log.e("HealthConnect", "❌ All methods to open Health Connect settings failed") false } catch (e: Exception) { android.util.Log.e("HealthConnect", "❌ Unexpected error opening Health Connect settings: ${e.message}", e) false } } override fun definition() = ModuleDefinition { Name("ExpoHealthkitModule") Constants( "PI" to Math.PI ) Events("onChange") Function("hello") { "Hello world! πŸ‘‹" } AsyncFunction("setValueAsync") { value: String -> sendEvent("onChange", mapOf( "value" to value )) } AsyncFunction("openHealthConnectApp") { promise: Promise -> android.util.Log.d("HealthConnect", "πŸ”΅ openHealthConnectApp called") val activity = appContext.activityProvider?.currentActivity if (activity == null) { android.util.Log.e("HealthConnect", "❌ No foreground Activity available") promise.resolve(mapOf( "success" to false, "error" to "No foreground Activity available" )) return@AsyncFunction } val opened = openHealthConnectSettings(activity) promise.resolve(mapOf( "success" to opened, "error" to if (opened) null else "Failed to open Health Connect app" )) } AsyncFunction("authorizeHealthKit") { promise: Promise -> android.util.Log.d("HealthConnect", "🟒 authorizeHealthKit called") if (!isHealthConnectAvailable()) { android.util.Log.e("HealthConnect", "❌ Health Connect not available") promise.resolve(mapOf( "success" to false, "granted" to emptyList(), "denied" to emptyList(), "error" to "Health Connect is not available on this device. Please install Google Health Connect." )) return@AsyncFunction } android.util.Log.d("HealthConnect", "βœ… Health Connect is available") val activity = appContext.activityProvider?.currentActivity if (activity == null) { android.util.Log.e("HealthConnect", "❌ No foreground Activity available") promise.resolve(mapOf( "success" to false, "granted" to emptyList(), "denied" to emptyList(), "error" to "No foreground Activity available to request Health Connect permissions." )) return@AsyncFunction } android.util.Log.d("HealthConnect", "βœ… Activity available: ${activity::class.simpleName}") val requestedPermissions: Set = recordTypeMap.values.map { it.second }.toSet() android.util.Log.d("HealthConnect", "πŸ“‹ Requesting ${requestedPermissions.size} permissions") moduleScope.launch { try { android.util.Log.d("HealthConnect", "πŸ” Checking already granted permissions...") val alreadyGranted: Set = healthConnectClient.permissionController.getGrantedPermissions() android.util.Log.d("HealthConnect", "βœ… Already granted: ${alreadyGranted.size} permissions") alreadyGranted.forEach { perm -> android.util.Log.d("HealthConnect", " βœ“ $perm") } if (alreadyGranted.containsAll(requestedPermissions)) { android.util.Log.d("HealthConnect", "πŸŽ‰ All permissions already granted!") promise.resolve(mapOf( "success" to true, "granted" to requestedPermissions.toList(), "denied" to emptyList(), "error" to null )) return@launch } val missingPermissions = requestedPermissions - alreadyGranted android.util.Log.d("HealthConnect", "⚠️ Missing ${missingPermissions.size} permissions, requesting...") missingPermissions.forEach { perm -> android.util.Log.d("HealthConnect", " βœ— $perm") } pendingPermissionPromise = promise requestedPermissionsSet = requestedPermissions android.util.Log.d("HealthConnect", "πŸš€ Requesting permissions via Health Connect SDK...") try { val componentActivity = activity as? androidx.activity.ComponentActivity if (componentActivity == null) { android.util.Log.e("HealthConnect", "❌ Current activity is not a ComponentActivity") pendingPermissionPromise = null requestedPermissionsSet = null promise.resolve(mapOf( "success" to false, "granted" to alreadyGranted.toList(), "denied" to missingPermissions.toList(), "error" to "Cannot request permissions: Activity is not a ComponentActivity." )) } else { val contract = PermissionController.createRequestPermissionResultContract() val launcher = componentActivity.activityResultRegistry.register( "hc-permissions-${System.currentTimeMillis()}", contract ) { granted: Set -> android.util.Log.d("HealthConnect", "πŸ”Ž Permission result received: granted=${granted.size}") granted.forEach { perm -> android.util.Log.d("HealthConnect", " βœ“ Granted: $perm") } // Check if any permissions were granted if (granted.isNotEmpty()) { // Some or all permissions were granted val requested = requestedPermissionsSet ?: emptySet() val denied = (requested - granted).toList() android.util.Log.d("HealthConnect", "βœ… Permissions granted: ${granted.size}, denied: ${denied.size}") pendingPermissionPromise?.resolve(mapOf( "success" to denied.isEmpty(), "granted" to granted.toList(), "denied" to denied, "error" to null )) pendingPermissionPromise = null requestedPermissionsSet = null } else { // No permissions granted - this can happen on Android 14+ if: // 1. The schema isn't recognized yet by the system (first-time setup) // 2. User explicitly denied all permissions // 3. The permission dialog was dismissed android.util.Log.d("HealthConnect", "⚠️ No permissions granted on first attempt") android.util.Log.d("HealthConnect", "ℹ️ This is normal for Android 14+ on first run - schema needs to be registered") // On Android 14+, the first permission request can fail because the system // hasn't registered the app's health schema yet. We need to guide the user // to open Health Connect manually. val requested = requestedPermissionsSet ?: emptySet() val denied = requested.toList() // Resolve with a specific error that the UI can handle pendingPermissionPromise?.resolve(mapOf( "success" to false, "granted" to emptyList(), "denied" to denied, "error" to "MANUAL_SETUP_REQUIRED", "message" to "Please open the Health Connect app and grant permissions to Fithack manually. Then return to this screen and try again.", "action" to "open_health_connect" )) pendingPermissionPromise = null requestedPermissionsSet = null // Try to open Health Connect app to make it easier for the user android.util.Log.d("HealthConnect", "πŸ”„ Attempting to open Health Connect app...") openHealthConnectSettings(componentActivity) } } android.util.Log.d("HealthConnect", "πŸš€ Launching permission request for ${requestedPermissions.size} permissions") launcher.launch(requestedPermissions) android.util.Log.d("HealthConnect", "βœ… Permission flow launched") } } catch (e: Exception) { android.util.Log.e("HealthConnect", "❌ Failed to launch permission intent: ${e.message}", e) e.printStackTrace() try { android.util.Log.d("HealthConnect", "πŸ”„ Trying fallback: Opening Health Connect app...") val launchIntent = context.packageManager.getLaunchIntentForPackage("com.google.android.apps.healthdata") if (launchIntent != null) { activity.startActivity(launchIntent) android.util.Log.d("HealthConnect", "βœ… Health Connect app opened - user needs to manually grant permissions") } else { android.util.Log.e("HealthConnect", "❌ Cannot find Health Connect app") pendingPermissionPromise = null requestedPermissionsSet = null promise.resolve(mapOf( "success" to false, "granted" to alreadyGranted.toList(), "denied" to missingPermissions.toList(), "error" to "Health Connect app not found. Please install it from Play Store." )) } } catch (fallbackError: Exception) { android.util.Log.e("HealthConnect", "❌ Fallback also failed: ${fallbackError.message}", fallbackError) pendingPermissionPromise = null requestedPermissionsSet = null promise.resolve(mapOf( "success" to false, "granted" to alreadyGranted.toList(), "denied" to missingPermissions.toList(), "error" to "Failed to open Health Connect: ${e.message}" )) } } } catch (e: Exception) { android.util.Log.e("HealthConnect", "❌ Exception in authorizeHealthKit: ${e.message}", e) promise.resolve(mapOf( "success" to false, "granted" to emptyList(), "denied" to requestedPermissions.toList(), "error" to ("Failed to request Health Connect permissions: ${e.message}") )) } } } AsyncFunction("getHealthData") { options: Map, promise: Promise -> android.util.Log.d("HealthConnect", "πŸ”΅ getHealthData called with options: $options") val identifier = options["identifier"] val startDateString = options["startDate"] val endDateString = options["endDate"] if (identifier == null || startDateString == null || endDateString == null) { android.util.Log.e("HealthConnect", "❌ Missing required arguments") promise.resolve(mapOf( "success" to false, "data" to emptyList(), "error" to mapOf( "code" to "missing_arguments", "message" to "Missing required options: identifier, startDate, endDate." ) )) return@AsyncFunction } if (!isHealthConnectAvailable()) { android.util.Log.e("HealthConnect", "❌ Health Connect not available") promise.resolve(mapOf( "success" to false, "data" to emptyList(), "error" to mapOf( "code" to "health_connect_unavailable", "message" to "Health Connect is not available on this device." ) )) return@AsyncFunction } android.util.Log.d("HealthConnect", "βœ… Fetching data for identifier: $identifier") val recordTypeInfo = recordTypeMap[identifier] if (recordTypeInfo == null) { promise.resolve(mapOf( "success" to false, "data" to emptyList(), "error" to mapOf( "code" to "unsupported_identifier", "message" to "Identifier '$identifier' not supported. Available: ${recordTypeMap.keys.joinToString(", ")}" ) )) return@AsyncFunction } moduleScope.launch { try { val startTime = Instant.parse(startDateString) val endTime = Instant.parse(endDateString) val request = ReadRecordsRequest( recordType = recordTypeInfo.first, timeRangeFilter = TimeRangeFilter.between(startTime, endTime) ) val response = healthConnectClient.readRecords(request) val formattedData = response.records.map { record -> formatRecordToStandardFormat(record) } promise.resolve(mapOf( "success" to true, "data" to formattedData, "error" to null )) } catch (e: Exception) { android.util.Log.e("HealthConnect", "❌ Failed to fetch health data: ${e.message}", e) promise.resolve(mapOf( "success" to false, "data" to emptyList(), "error" to mapOf( "code" to "query_error", "message" to "Failed to fetch data: ${e.message}" ) )) } } } AsyncFunction("enableBackgroundSync") { options: Map, promise: Promise -> promise.resolve(mapOf( "success" to false, "error" to "Background sync handled by Expo BackgroundTask service. Enable sync in main app." )) } AsyncFunction("getBackgroundSyncStatus") { promise: Promise -> promise.resolve(mapOf( "enabled" to false, "lastSync" to null )) } AsyncFunction("disableBackgroundSync") { promise: Promise -> promise.resolve(mapOf( "success" to true, "error" to null )) } AsyncFunction("exportAllHealthData") { options: Map, promise: Promise -> android.util.Log.d("HealthConnect", "πŸ”΅ exportAllHealthData called") val startDateString = options["startDate"] val endDateString = options["endDate"] if (startDateString == null || endDateString == null) { android.util.Log.e("HealthConnect", "❌ Missing date arguments") promise.resolve(mapOf( "success" to false, "data" to emptyMap(), "error" to mapOf( "code" to "missing_arguments", "message" to "Missing required options: startDate, endDate." ) )) return@AsyncFunction } if (!isHealthConnectAvailable()) { android.util.Log.e("HealthConnect", "❌ Health Connect not available") promise.resolve(mapOf( "success" to false, "data" to emptyMap(), "error" to mapOf( "code" to "health_connect_unavailable", "message" to "Health Connect is not available on this device." ) )) return@AsyncFunction } android.util.Log.d("HealthConnect", "βœ… Starting export of all health data") moduleScope.launch { try { val startTime = Instant.parse(startDateString) val endTime = Instant.parse(endDateString) val allData = mutableMapOf() for ((identifier, recordTypeInfo) in recordTypeMap) { try { android.util.Log.d("HealthConnect", "Fetching data for: $identifier") val request = ReadRecordsRequest( recordType = recordTypeInfo.first, timeRangeFilter = TimeRangeFilter.between(startTime, endTime) ) val response = healthConnectClient.readRecords(request) val rawRecords = response.records.map { record -> mapOf( "rawRecord" to record.toString(), "recordClass" to record::class.simpleName, "metadata" to mapOf( "dataOrigin" to (record.metadata.dataOrigin.packageName ?: "Unknown"), "id" to (record.metadata.id ?: ""), "clientRecordId" to (record.metadata.clientRecordId ?: ""), "lastModifiedTime" to (record.metadata.lastModifiedTime?.toString() ?: ""), "clientRecordVersion" to (record.metadata.clientRecordVersion ?: 0), "device" to mapOf( "manufacturer" to (record.metadata.device?.manufacturer ?: ""), "model" to (record.metadata.device?.model ?: ""), "type" to (record.metadata.device?.type ?: 0) ) ), "formattedRecord" to formatRecordToStandardFormat(record) ) } allData[identifier] = mapOf( "count" to rawRecords.size, "records" to rawRecords ) android.util.Log.d("HealthConnect", "βœ… Fetched ${rawRecords.size} records for $identifier") } catch (e: Exception) { android.util.Log.e("HealthConnect", "❌ Error fetching $identifier: ${e.message}", e) allData[identifier] = mapOf( "count" to 0, "error" to e.message, "records" to emptyList() ) } } promise.resolve(mapOf( "success" to true, "data" to allData, "exportInfo" to mapOf( "startDate" to startDateString, "endDate" to endDateString, "exportTime" to Instant.now().toString(), "totalTypes" to recordTypeMap.size, "availableTypes" to recordTypeMap.keys.toList() ), "error" to null )) } catch (e: Exception) { android.util.Log.e("HealthConnect", "❌ Export failed: ${e.message}", e) promise.resolve(mapOf( "success" to false, "data" to emptyMap(), "error" to mapOf( "code" to "export_error", "message" to "Failed to export data: ${e.message}" ) )) } } } View(ExpoHealthkitModuleView::class) { Prop("url") { view: ExpoHealthkitModuleView, url: URL -> view.webView.loadUrl(url.toString()) } Events("onLoad") } OnActivityEntersForeground { android.util.Log.d("HealthConnect", "πŸ”„ Activity entered foreground") // Note: We no longer keep the promise alive when opening Health Connect // Instead, we resolve immediately and let the user try again after granting permissions manually // This approach is more reliable on Android 14+ where the first permission request often fails } } }