package com.tracked.health import android.content.Context import android.util.Log import androidx.health.connect.client.HealthConnectClient import androidx.health.connect.client.changes.UpsertionChange import androidx.health.connect.client.permission.HealthPermission import androidx.health.connect.client.records.SleepSessionRecord import androidx.health.connect.client.request.ChangesTokenRequest import androidx.work.ExistingWorkPolicy import androidx.work.OneTimeWorkRequestBuilder import androidx.work.WorkManager import java.time.ZoneId import java.util.concurrent.TimeUnit internal object SleepBackgroundSync { private const val TAG = "SleepBackgroundSync" private const val PREFS_FILE = "com.tracked.health.sleep" private const val KEY_SLEEP_TOKEN = "sleep_changes_token" private const val KEY_FREQUENCY = "sleep_update_frequency" private const val WORK_NAME = "SleepChangeSync" suspend fun enable(context: Context, frequency: String): Boolean { val appContext = context.applicationContext val client = HealthConnectClient.getOrCreate(appContext) val requiredPermissions = setOf( HealthPermission.getReadPermission(SleepSessionRecord::class) ) val granted = client.permissionController.getGrantedPermissions() if (!granted.containsAll(requiredPermissions)) { Log.w(TAG, "Cannot enable sleep updates without Health Connect permissions") return false } val tokenRequest = ChangesTokenRequest( recordTypes = setOf(SleepSessionRecord::class) ) val token = client.getChangesToken(tokenRequest) preferences(appContext).edit() .putString(KEY_SLEEP_TOKEN, token) .putString(KEY_FREQUENCY, frequency) .apply() return true } suspend fun disable(context: Context): Boolean { val appContext = context.applicationContext preferences(appContext).edit() .remove(KEY_SLEEP_TOKEN) .remove(KEY_FREQUENCY) .apply() WorkManager.getInstance(appContext).cancelUniqueWork(WORK_NAME) return true } fun scheduleSync(context: Context, immediate: Boolean = false) { val appContext = context.applicationContext val prefs = preferences(appContext) val requestedFrequency = prefs.getString(KEY_FREQUENCY, null) val requestBuilder = OneTimeWorkRequestBuilder() .addTag(WORK_NAME) val delayMinutes = if (immediate) 0L else frequencyToDelayMinutes(requestedFrequency) if (delayMinutes > 0) { requestBuilder.setInitialDelay(delayMinutes, TimeUnit.MINUTES) } val workRequest = requestBuilder.build() WorkManager.getInstance(appContext).enqueueUniqueWork( WORK_NAME, ExistingWorkPolicy.APPEND_OR_REPLACE, workRequest ) } /** * Drains the Health Connect changes feed for sleep sessions and returns every * upserted session (mapped for JS). Processing the actual change set — rather * than reading a single "latest" record — means a multi-night backfill or a * correction to an older session is fully delivered instead of dropped. */ suspend fun pullLatestChanges(context: Context): List> { val appContext = context.applicationContext val client = HealthConnectClient.getOrCreate(appContext) val prefs = preferences(appContext) val tokenRequest = ChangesTokenRequest( recordTypes = setOf(SleepSessionRecord::class) ) val currentToken = prefs.getString(KEY_SLEEP_TOKEN, null) ?: client.getChangesToken(tokenRequest).also { token -> prefs.edit().putString(KEY_SLEEP_TOKEN, token).apply() } val changedSessions = mutableListOf>() var nextToken = currentToken var keepReading = true var tokenRetries = 0 val maxTokenRetries = 3 while (keepReading) { val response = client.getChanges(nextToken) if (response.changesTokenExpired) { tokenRetries++ if (tokenRetries > maxTokenRetries) { Log.e(TAG, "Sleep changes token expired $maxTokenRetries times; aborting") break } Log.w(TAG, "Sleep changes token expired (attempt $tokenRetries/$maxTokenRetries); requesting a fresh token") nextToken = client.getChangesToken(tokenRequest) prefs.edit().putString(KEY_SLEEP_TOKEN, nextToken).apply() continue } for (change in response.changes) { val record = (change as? UpsertionChange)?.record as? SleepSessionRecord ?: continue changedSessions += mapSleepSession(record) } nextToken = response.nextChangesToken keepReading = response.hasMore } prefs.edit().putString(KEY_SLEEP_TOKEN, nextToken).apply() return changedSessions } private fun sleepStageTypeToString(stage: Int): String { return when (stage) { SleepSessionRecord.STAGE_TYPE_UNKNOWN -> "Unknown" SleepSessionRecord.STAGE_TYPE_AWAKE -> "Awake" SleepSessionRecord.STAGE_TYPE_SLEEPING -> "Sleeping" SleepSessionRecord.STAGE_TYPE_OUT_OF_BED -> "OutOfBed" SleepSessionRecord.STAGE_TYPE_LIGHT -> "Light" SleepSessionRecord.STAGE_TYPE_DEEP -> "Deep" SleepSessionRecord.STAGE_TYPE_REM -> "REM" SleepSessionRecord.STAGE_TYPE_AWAKE_IN_BED -> "AwakeInBed" else -> "Unknown" } } private fun mapSleepSession(record: SleepSessionRecord): Map { val startTimeMs = record.startTime.toEpochMilli() val endTimeMs = record.endTime.toEpochMilli() val totalDuration = endTimeMs - startTimeMs val stages = record.stages.map { stage -> val stageStartMs = stage.startTime.toEpochMilli() val stageEndMs = stage.endTime.toEpochMilli() val stageDuration = stageEndMs - stageStartMs mapOf( "type" to sleepStageTypeToString(stage.stage), "startTime" to stageStartMs, "endTime" to stageEndMs, "duration" to stageDuration ) } return mapOf( "startTime" to startTimeMs, "endTime" to endTimeMs, "totalDuration" to totalDuration, "isoStartDate" to record.startTime.toString(), "isoEndDate" to record.endTime.toString(), "stages" to stages, "source" to record.metadata.dataOrigin.packageName ) } private fun preferences(context: Context) = context.getSharedPreferences(PREFS_FILE, Context.MODE_PRIVATE) private fun frequencyToDelayMinutes(frequency: String?): Long = when (frequency?.lowercase()) { "immediate" -> 15L "daily" -> 24L * 60L "weekly" -> 7L * 24L * 60L "hourly" -> 60L else -> 24L * 60L // Default to daily for sleep } }