Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
<uses-permission android:name="android.permission.health.WRITE_NUTRITION"/>
<uses-permission android:name="android.permission.health.READ_NUTRITION"/>
<uses-permission android:name="android.permission.health.READ_SLEEP"/>
<uses-permission android:name="android.permission.health.WRITE_HYDRATION"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>

Expand Down
46 changes: 42 additions & 4 deletions app/src/main/java/com/uc/caffeine/data/HealthConnectManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,15 @@ package com.uc.caffeine.data
import android.content.Context
import androidx.health.connect.client.HealthConnectClient
import androidx.health.connect.client.permission.HealthPermission
import androidx.health.connect.client.records.HydrationRecord
import androidx.health.connect.client.records.NutritionRecord
import androidx.health.connect.client.records.Record
import androidx.health.connect.client.records.SleepSessionRecord
import androidx.health.connect.client.records.metadata.Metadata as HCMetadata
import androidx.health.connect.client.request.ReadRecordsRequest
import androidx.health.connect.client.time.TimeRangeFilter
import androidx.health.connect.client.units.Mass
import androidx.health.connect.client.units.Volume
import com.uc.caffeine.data.model.ConsumptionEntry
import java.time.Duration
import java.time.Instant
Expand Down Expand Up @@ -36,8 +39,9 @@ class HealthConnectManager(private val context: Context) {
private val nutritionWrite = HealthPermission.getWritePermission(NutritionRecord::class)
private val nutritionRead = HealthPermission.getReadPermission(NutritionRecord::class)
private val sleepRead = HealthPermission.getReadPermission(SleepSessionRecord::class)
private val hydrationWrite = HealthPermission.getWritePermission(HydrationRecord::class)

val allPermissions = setOf(nutritionWrite, nutritionRead, sleepRead)
val allPermissions = setOf(nutritionWrite, nutritionRead, sleepRead, hydrationWrite)
val sleepPermissions = setOf(sleepRead)

fun isAvailable(): Boolean =
Expand All @@ -62,11 +66,15 @@ class HealthConnectManager(private val context: Context) {
suspend fun hasSleepPermission(): Boolean =
grantedPermissionsOrNull()?.contains(sleepRead) == true

suspend fun hasHydrationWritePermission(): Boolean =
grantedPermissionsOrNull()?.contains(hydrationWrite) == true

/** True only when Health Connect answered and the sleep permission is definitively not granted. */
suspend fun isSleepPermissionRevoked(): Boolean =
grantedPermissionsOrNull()?.let { sleepRead !in it } == true

private fun clientRecordId(entryId: Int): String = "caffeine_entry_$entryId"
private fun clientHydrationRecordId(entryId: Int): String = "caffeine_entry_${entryId}_hydration"

private fun ConsumptionEntry.toNutritionRecord(zoneId: ZoneId): NutritionRecord {
val start = Instant.ofEpochMilli(startedAtMillis)
Expand All @@ -86,14 +94,37 @@ class HealthConnectManager(private val context: Context) {
)
}

suspend fun writeEntry(entry: ConsumptionEntry, zoneId: ZoneId = ZoneId.systemDefault()) {
private fun ConsumptionEntry.toHydrationRecord(volumeMl: Double, zoneId: ZoneId): HydrationRecord {
val start = Instant.ofEpochMilli(startedAtMillis)
val end = start.plusSeconds(normalizedDurationMinutes * 60L)
val zoneRules = zoneId.rules
return HydrationRecord(
startTime = start,
endTime = end,
volume = Volume.liters(volumeMl / 1000.0),
startZoneOffset = zoneRules.getOffset(start),
endZoneOffset = zoneRules.getOffset(end),
metadata = HCMetadata.manualEntry(
clientRecordId = clientHydrationRecordId(id),
clientRecordVersion = System.currentTimeMillis(),
),
)
}

suspend fun writeEntry(
entry: ConsumptionEntry,
zoneId: ZoneId = ZoneId.systemDefault(),
volumeMl: Double? = null,
) {
val c = client ?: return
// Imported records are owned by another app — never echo them back
if (entry.healthConnectRecordId != null) return
if (!hasPermission()) return
runCatching {
c.insertRecords(listOf(entry.toNutritionRecord(zoneId)))
val records = mutableListOf<Record>(entry.toNutritionRecord(zoneId))
if (volumeMl != null && volumeMl > 0.0 && hasHydrationWritePermission()) {
records.add(entry.toHydrationRecord(volumeMl, zoneId))
}
runCatching { c.insertRecords(records) }
}

suspend fun syncAll(entries: List<ConsumptionEntry>, zoneId: ZoneId = ZoneId.systemDefault()) {
Expand Down Expand Up @@ -129,6 +160,13 @@ class HealthConnectManager(private val context: Context) {
runCatching {
c.deleteRecords(NutritionRecord::class, emptyList(), clientIds)
}
// Also delete corresponding hydration records
if (hasHydrationWritePermission()) {
val hydrationClientIds = entryIds.map { clientHydrationRecordId(it) }
runCatching {
c.deleteRecords(HydrationRecord::class, emptyList(), hydrationClientIds)
}
}
}

suspend fun readForeignCaffeineRecords(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -576,7 +576,8 @@ class CaffeineViewModel(application: Application) : AndroidViewModel(application
val settings = userSettings.value
if (settings.healthConnectEnabled) {
val zoneId = settings.resolvedZoneId()
runCatching { healthConnectManager.writeEntry(entry.copy(id = newId.toInt()), zoneId) }
val volumeMl = defaultUnit.milliliters
runCatching { healthConnectManager.writeEntry(entry.copy(id = newId.toInt()), zoneId, volumeMl) }
}
}
}
Expand All @@ -602,7 +603,8 @@ class CaffeineViewModel(application: Application) : AndroidViewModel(application
val settings = userSettings.value
if (settings.healthConnectEnabled) {
val zoneId = settings.resolvedZoneId()
runCatching { healthConnectManager.writeEntry(entry.copy(id = newId.toInt()), zoneId) }
val volumeMl = unit.milliliters?.let { it * quantity }
runCatching { healthConnectManager.writeEntry(entry.copy(id = newId.toInt()), zoneId, volumeMl) }
}
}
}
Expand Down Expand Up @@ -703,7 +705,8 @@ class CaffeineViewModel(application: Application) : AndroidViewModel(application
durationMinutes = coercedDuration,
)
val zoneId = settings.resolvedZoneId()
runCatching { healthConnectManager.writeEntry(updatedEntry, zoneId) }
val volumeMl = unit.milliliters?.let { it * quantity }
runCatching { healthConnectManager.writeEntry(updatedEntry, zoneId, volumeMl) }
}
}
}
Expand Down