diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 75dc583..ebef82d 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -19,12 +19,23 @@ android {
// versionCode/versionName are overridable from Gradle properties so the release CI
// can drive them straight from the git tag (e.g. -PappVersionCode=5 -PappVersionName=1.0.0).
// Local builds fall back to the literals below.
- versionCode = (project.findProperty("appVersionCode") as String?)?.toIntOrNull() ?: 33
- versionName = (project.findProperty("appVersionName") as String?) ?: "1.0.0"
+ versionCode = (project.findProperty("appVersionCode") as String?)?.toIntOrNull() ?: 34
+ versionName = (project.findProperty("appVersionName") as String?) ?: "2.5.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
// Repo the self-updater polls for new releases.
buildConfigField("String", "GITHUB_REPO", "\"foureight84/PulseLoopAndroid\"")
+
+ // Strava OAuth — credentials from local.properties (gitignored), fall back to
+ // empty placeholders so non-Strava builds compile without the file.
+ val localProps = Properties().apply {
+ val f = rootProject.file("local.properties")
+ if (f.exists()) f.inputStream().use { load(it) }
+ }
+ buildConfigField("String", "STRAVA_CLIENT_ID",
+ "\"${localProps.getProperty("stravaClientId") ?: ""}\"")
+ buildConfigField("String", "STRAVA_CLIENT_SECRET",
+ "\"${localProps.getProperty("stravaClientSecret") ?: ""}\"")
}
buildFeatures {
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index fe65595..bf62e99 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -50,6 +50,13 @@
+
+
+
+
+
+
+
diff --git a/app/src/main/java/com/pulseloop/MainActivity.kt b/app/src/main/java/com/pulseloop/MainActivity.kt
index 53fcdad..2f8e9a2 100644
--- a/app/src/main/java/com/pulseloop/MainActivity.kt
+++ b/app/src/main/java/com/pulseloop/MainActivity.kt
@@ -1,6 +1,7 @@
package com.pulseloop
import android.Manifest
+import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
@@ -10,7 +11,12 @@ import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat
import com.pulseloop.notifications.CoachNotifications
+import com.pulseloop.strava.StravaAuth
+import com.pulseloop.strava.StravaTokenStore
import com.pulseloop.ui.PulseLoopApp
+import androidx.lifecycle.lifecycleScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
/**
* Single-activity host for the PulseLoop Compose UI.
@@ -52,6 +58,9 @@ class MainActivity : ComponentActivity() {
setContent {
PulseLoopApp()
}
+
+ // Handle Strava redirect on cold start (app launched from browser callback).
+ handleStravaRedirect(intent)
}
override fun onResume() {
@@ -63,6 +72,63 @@ class MainActivity : ComponentActivity() {
}
}
+ override fun onNewIntent(intent: Intent) {
+ super.onNewIntent(intent)
+ handleStravaRedirect(intent)
+ }
+
+ /**
+ * Strava OAuth callback (`pulseloop://localhost/strava-auth?…`). Reached via `onNewIntent` for
+ * the usual case, and via `onCreate` when the browser round-trip outlived our process.
+ *
+ * Failures are recorded in the token store rather than dropped, so the Strava settings screen
+ * can say what went wrong — a silent return leaves the user staring at a "Connect" button that
+ * appears to do nothing.
+ */
+ private fun handleStravaRedirect(intent: Intent) {
+ val uri = intent.data ?: return
+ if (uri.scheme != "pulseloop") return
+ if (!StravaAuth.isConfigured) return
+ val store = StravaTokenStore(this)
+
+ // Strava returns `error=access_denied` when the user declines on the consent screen.
+ uri.getQueryParameter("error")?.takeIf { it.isNotBlank() }?.let { error ->
+ store.takePendingAuthState()
+ store.saveLastError(
+ if (error == "access_denied") "Strava authorization was declined." else "Strava denied authorization: $error"
+ )
+ return
+ }
+
+ // CSRF: the state we generated must come back. One-shot, cleared either way.
+ if (!StravaAuth.validateState(store, uri.getQueryParameter("state"))) {
+ store.saveLastError("Strava authorization could not be verified. Please try connecting again.")
+ return
+ }
+
+ // The consent screen lets the user untick "Upload your activities". Catch it here rather
+ // than letting every future upload fail with an opaque 401.
+ if (!StravaAuth.grantedScopeIncludesWrite(uri.getQueryParameter("scope"))) {
+ store.saveLastError("Strava did not grant upload permission. Reconnect and keep \"Upload your activities\" checked.")
+ return
+ }
+
+ val code = uri.getQueryParameter("code")?.takeIf { it.isNotBlank() } ?: run {
+ store.saveLastError("Strava returned an unexpected authorization response.")
+ return
+ }
+
+ lifecycleScope.launch(Dispatchers.IO) {
+ try {
+ store.save(StravaAuth.exchangeCode(code))
+ store.clearLastError()
+ } catch (e: Exception) {
+ // The code may have expired, or the configured secrets are wrong.
+ store.saveLastError("Could not complete the Strava sign-in: ${e.message ?: "unknown error"}")
+ }
+ }
+ }
+
// ── Permission checks ──────────────────────────────────────────────
fun hasAllBlePermissions(): Boolean = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
diff --git a/app/src/main/java/com/pulseloop/data/DataArchive.kt b/app/src/main/java/com/pulseloop/data/DataArchive.kt
new file mode 100644
index 0000000..4cb033f
--- /dev/null
+++ b/app/src/main/java/com/pulseloop/data/DataArchive.kt
@@ -0,0 +1,244 @@
+package com.pulseloop.data
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class PulseArchive(
+ val formatVersion: Int = 1,
+ val exportedAt: Long,
+ val appVersion: String,
+ val counts: Map = emptyMap(),
+ val devices: List = emptyList(),
+ val measurements: List = emptyList(),
+ val activityDaily: List = emptyList(),
+ val activityBuckets: List = emptyList(),
+ val batterySamples: List = emptyList(),
+ val deviceMeasurementConfigs: List = emptyList(),
+ val activitySessions: List = emptyList(),
+ val activityGpsPoints: List = emptyList(),
+ val activityEvents: List = emptyList(),
+ val activitySamples: List = emptyList(),
+ val activitySensorPolls: List = emptyList(),
+ val sleepSessions: List = emptyList(),
+ val sleepStageBlocks: List = emptyList(),
+ val coachConversations: List = emptyList(),
+ val coachMessages: List = emptyList(),
+ val coachMemories: List = emptyList(),
+ val coachToolCalls: List = emptyList(),
+ val userProfiles: List = emptyList(),
+ val userGoals: List = emptyList(),
+ val rawPackets: List = emptyList(),
+ val derivedUpdates: List = emptyList(),
+ val coachSummaries: List = emptyList(),
+ val wearableLogs: List = emptyList(),
+ val coachNotificationRecords: List = emptyList(),
+ /** iOS #96 nutrition. Not in iOS's own `DataArchive.swift` — see the note on [MealEntryDTO]. */
+ val mealEntries: List = emptyList(),
+ val foodProducts: List = emptyList(),
+)
+
+@Serializable data class DeviceDTO(
+ val id: String, val name: String, val advertisedName: String? = null,
+ val peripheralIdentifier: String? = null, val bleAddressHint: String? = null,
+ val batteryPercent: Int? = null, val stateRaw: String, val deviceTypeRaw: String,
+ val wearableModelID: String? = null, val capabilitiesRaw: String,
+ val lastConnectedAt: Long? = null, val lastDisconnectedAt: Long? = null,
+ val lastSyncAt: Long? = null, val lastFullSyncAt: Long? = null,
+ val firmwareVersion: String? = null, val createdAt: Long, val updatedAt: Long,
+)
+
+@Serializable data class MeasurementDTO(
+ val id: String, val kindRaw: String, val value: Double, val unit: String,
+ val timestamp: Long, val sourceRaw: String, val confidenceRaw: String = "known",
+ val activitySessionId: String? = null, val rawPacketId: String? = null,
+ val createdAt: Long,
+)
+
+@Serializable data class ActivityDailyDTO(
+ val id: String, val date: Long, val steps: Int = 0, val calories: Double = 0.0,
+ val distanceMeters: Double = 0.0, val activeMinutes: Int = 0,
+ val source: String = "mock", val syncedAt: Long? = null,
+ val createdAt: Long, val updatedAt: Long,
+ val estimatedActiveCalories: Double? = null,
+)
+
+@Serializable data class ActivityBucketDTO(
+ val startEpoch: Long, val date: Long, val steps: Int = 0,
+ val distanceMeters: Double = 0.0, val source: String = "ring_history",
+ val updatedAt: Long,
+)
+
+@Serializable data class BatterySampleDTO(
+ val id: String, val percent: Int, val timestamp: Long, val createdAt: Long,
+)
+
+@Serializable data class DeviceMeasurementConfigDTO(
+ val deviceId: String, val hrIntervalMinutes: Int = 5, val hrEnabled: Boolean = true,
+ val spo2Enabled: Boolean = true, val stressEnabled: Boolean = true,
+ val hrvEnabled: Boolean = true, val temperatureEnabled: Boolean = true,
+ val updatedAt: Long,
+)
+
+@Serializable data class ActivitySessionDTO(
+ val id: String, val type: String, val statusRaw: String = "recording",
+ val startedAt: Long, val endedAt: Long? = null, val totalPauseSeconds: Double = 0.0,
+ val calories: Double? = null, val distanceMeters: Double? = null,
+ val avgHeartRate: Double? = null, val minHeartRate: Double? = null,
+ val maxHeartRate: Double? = null, val avgSpO2: Double? = null,
+ val latestSpO2: Double? = null, val notes: String? = null,
+ val useGps: Boolean = true, val perceivedEffort: String? = null,
+ val gpsPointCount: Int = 0, val rejectedGpsPointCount: Int = 0,
+ val hrPollCount: Int = 0, val hrPollFailureCount: Int = 0,
+ val spo2PollCount: Int = 0, val spo2PollFailureCount: Int = 0,
+ val liveActivityID: String? = null, val lastSensorPollAt: Long? = null,
+ val lastGpsPointAt: Long? = null, val stravaActivityId: Long? = null,
+ val createdAt: Long, val updatedAt: Long,
+)
+
+@Serializable data class ActivityGpsPointDTO(
+ val id: String, val sessionId: String, val latitude: Double, val longitude: Double,
+ val altitude: Double? = null, val horizontalAccuracy: Double? = null,
+ val speed: Double? = null, val course: Double? = null, val timestamp: Long,
+ val accepted: Boolean = true, val rejectionReason: String? = null,
+)
+
+@Serializable data class ActivityEventDTO(
+ val id: String, val sessionId: String, val kind: String, val timestamp: Long,
+ val payloadJSON: String? = null,
+)
+
+@Serializable data class ActivitySampleDTO(
+ val id: String, val sessionId: String, val measurementId: String? = null,
+ val kind: String, val value: Double, val unit: String, val timestamp: Long,
+ val source: String = "mock", val confidenceRaw: String = "known",
+)
+
+@Serializable data class ActivitySensorPollDTO(
+ val id: String, val sessionId: String, val timestamp: Long,
+ val kind: String, val status: String, val value: Double? = null,
+ val errorMessage: String? = null,
+)
+
+@Serializable data class SleepSessionDTO(
+ val id: String, val date: Long, val startAt: Long, val endAt: Long,
+ val totalMinutes: Int, val score: Int? = null, val syncedAt: Long? = null,
+ val sourceRaw: String = "ring", val createdAt: Long, val updatedAt: Long,
+)
+
+@Serializable data class SleepStageBlockDTO(
+ val id: String, val sessionId: String, val startAt: Long, val startMinute: Int,
+ val durationMinutes: Int, val stageRaw: String,
+)
+
+@Serializable data class CoachConversationDTO(
+ val id: String, val title: String = "Today check-in", val createdAt: Long,
+ val updatedAt: Long, val totalInputTokens: Int = 0, val totalOutputTokens: Int = 0,
+ val totalCostUSD: Double = 0.0,
+)
+
+@Serializable data class CoachMessageDTO(
+ val id: String, val conversationId: String, val role: String, val body: String,
+ val cardsJSON: String? = null, val pendingActionJSON: String? = null,
+ val attachmentsJson: String? = null, val createdAt: Long,
+ val inputTokens: Int? = null, val outputTokens: Int? = null,
+ val costUSD: Double? = null, val modelUsed: String? = null,
+ val providerUsed: String? = null,
+)
+
+@Serializable data class CoachMemoryDTO(
+ val id: String, val key: String, val value: String,
+ val memoryType: String = "note", val importance: Int = 3,
+ val expiresAt: Long? = null, val sourceMessageId: String? = null,
+ val isUserEditable: Boolean = true, val createdAt: Long, val updatedAt: Long,
+)
+
+@Serializable data class CoachToolCallDTO(
+ val id: String, val conversationId: String, val messageId: String? = null,
+ val toolName: String, val inputJSON: String? = null, val outputJSON: String? = null,
+ val label: String = "", val statusRaw: String = "success", val sequence: Int = 0,
+ val createdAt: Long,
+)
+
+@Serializable data class UserProfileDTO(
+ val id: String, val name: String? = null, val age: Int? = null,
+ val sex: String? = null, val heightCm: Double? = null, val weightKg: Double? = null,
+ val onboardingCompleted: Boolean = false, val baselineCompleted: Boolean = false,
+ val hrZoneModeRaw: String = "auto",
+ val hrRestingBaseline: Double? = null,
+ val hrRestingBaselineUpdatedAt: Long? = null,
+ val hrCustomLowUpper: Double? = null,
+ val hrCustomAthleticUpper: Double? = null,
+ val hrCustomElevatedStart: Double? = null,
+ val hrCustomHighStart: Double? = null,
+ val createdAt: Long, val updatedAt: Long,
+)
+
+@Serializable data class UserGoalDTO(
+ val id: String, val steps: Int = 10000, val distanceMeters: Double = 8000.0,
+ val calories: Int = 500, val sleepMinutes: Int = 480,
+ val activeMinutes: Int = 45, val workoutsPerWeek: Int = 4,
+ // iOS #96 intake goals. Defaulted so archives written before these existed still import.
+ val intakeCalories: Double? = null, val intakeProteinG: Double? = null,
+ val intakeCarbsG: Double? = null, val intakeFatG: Double? = null,
+ val nutritionEnabled: Boolean = false,
+ val updatedAt: Long,
+)
+
+@Serializable data class RawPacketDTO(
+ val id: String, val timestamp: Long, val directionRaw: String,
+ val commandId: Int, val hexPayload: String, val decodedKind: String? = null,
+ val decodedJSON: String? = null, val confidenceRaw: String = "unknown",
+ val createdAt: Long,
+)
+
+@Serializable data class DerivedUpdateDTO(
+ val id: String, val timestamp: Long, val kind: String,
+ val entityType: String, val entityId: String, val payloadJSON: String? = null,
+)
+
+@Serializable data class CoachSummaryDTO(
+ val id: String, val kind: String, val scopeKey: String,
+ val title: String, val body: String, val chipsJSON: String? = null,
+ val conversationId: String? = null, val dataSignature: String,
+ val createdAt: Long, val updatedAt: Long,
+)
+
+@Serializable data class WearableLogDTO(
+ val id: String, val timestamp: Long, val event: String,
+ val detail: String? = null, val deviceId: String? = null,
+ val categoryRaw: String? = null, val levelRaw: String? = null,
+)
+
+@Serializable data class CoachNotificationRecordDTO(
+ val id: String, val title: String, val body: String, val createdAt: Long,
+)
+
+/**
+ * iOS #96 meal log. **Android-originated — iOS's `DataArchive.swift` does not carry these.** Its
+ * exporter landed in the same week as the nutrition models and was never extended, so an iOS
+ * export→import silently drops the user's meals. Included here because the Android import dialog
+ * promises to "permanently delete everything currently in the app and replace it", and wiping
+ * meal_entries without restoring them would make that literally true in the worst way. Upstream
+ * candidate for iOS.
+ */
+@Serializable data class MealEntryDTO(
+ val id: String, val date: Long, val timestamp: Long, val name: String,
+ val mealTypeRaw: String = "snack", val calories: Double,
+ val proteinG: Double = 0.0, val carbsG: Double = 0.0, val fatG: Double = 0.0,
+ val fiberG: Double? = null, val sugarG: Double? = null, val sodiumMg: Double? = null,
+ val sourceRaw: String = "manual", val offProductCode: String? = null,
+ val servingDescription: String? = null, val servingGrams: Double? = null,
+ val quantity: Double = 1.0, val confidenceRaw: String = "medium",
+ val userEdited: Boolean = false, val notes: String? = null,
+ val loggedByCoach: Boolean = false, val createdAt: Long,
+)
+
+@Serializable data class CachedFoodProductDTO(
+ val code: String, val name: String, val brand: String? = null,
+ val energyKcal100g: Double, val protein100g: Double = 0.0,
+ val carbs100g: Double = 0.0, val fat100g: Double = 0.0,
+ val fiber100g: Double? = null, val sugars100g: Double? = null,
+ val saturatedFat100g: Double? = null, val sodiumMg100g: Double? = null,
+ val servingSizeText: String? = null, val servingQuantityG: Double? = null,
+ val lastUsedAt: Long, val useCount: Int = 0,
+)
diff --git a/app/src/main/java/com/pulseloop/data/DataArchiveService.kt b/app/src/main/java/com/pulseloop/data/DataArchiveService.kt
new file mode 100644
index 0000000..daeaa96
--- /dev/null
+++ b/app/src/main/java/com/pulseloop/data/DataArchiveService.kt
@@ -0,0 +1,626 @@
+package com.pulseloop.data
+
+import android.content.ContentValues
+import android.content.Context
+import android.database.Cursor
+import android.database.sqlite.SQLiteDatabase
+import android.net.Uri
+import androidx.core.content.FileProvider
+import androidx.room.withTransaction
+import androidx.sqlite.db.SimpleSQLiteQuery
+import com.pulseloop.BuildConfig
+import com.pulseloop.data.entity.*
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import kotlinx.serialization.json.Json
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+
+object DataArchiveService {
+
+ private val json = Json {
+ prettyPrint = true
+ encodeDefaults = true
+ ignoreUnknownKeys = true
+ }
+
+ private val dateFmt = SimpleDateFormat("yyyy-MM-dd-HHmm", Locale.US)
+
+ private fun insertRaw(db: PulseLoopDatabase, table: String, cv: ContentValues): Long =
+ db.openHelper.writableDatabase.insert(table, SQLiteDatabase.CONFLICT_NONE, cv)
+
+ suspend fun exportArchive(db: PulseLoopDatabase): PulseArchive = withContext(Dispatchers.Default) {
+ val counts = mutableMapOf()
+
+ fun collect(table: String, mapper: (Cursor) -> T): List {
+ val result = mutableListOf()
+ db.openHelper.readableDatabase.query(SimpleSQLiteQuery("SELECT * FROM $table ORDER BY rowid ASC")).use { cursor ->
+ while (cursor.moveToNext()) {
+ result.add(mapper(cursor))
+ }
+ }
+ counts[table] = result.size
+ return result
+ }
+
+ PulseArchive(
+ formatVersion = 1,
+ exportedAt = System.currentTimeMillis(),
+ appVersion = BuildConfig.VERSION_NAME,
+ counts = counts,
+ devices = collect("devices") { c ->
+ DeviceDTO(
+ id = c.str("id"), name = c.str("name"), advertisedName = c.strOrNull("advertisedName"),
+ peripheralIdentifier = c.strOrNull("peripheralIdentifier"), bleAddressHint = c.strOrNull("bleAddressHint"),
+ batteryPercent = c.intOrNull("batteryPercent"), stateRaw = c.str("stateRaw"),
+ deviceTypeRaw = c.str("deviceTypeRaw"), wearableModelID = c.strOrNull("wearableModelID"),
+ capabilitiesRaw = c.str("capabilitiesRaw"), lastConnectedAt = c.longOrNull("lastConnectedAt"),
+ lastDisconnectedAt = c.longOrNull("lastDisconnectedAt"), lastSyncAt = c.longOrNull("lastSyncAt"),
+ lastFullSyncAt = c.longOrNull("lastFullSyncAt"), firmwareVersion = c.strOrNull("firmwareVersion"),
+ createdAt = c.long("createdAt"), updatedAt = c.long("updatedAt"),
+ )
+ },
+ measurements = collect("measurements") { c ->
+ MeasurementDTO(
+ id = c.str("id"), kindRaw = c.str("kindRaw"), value = c.dbl("value"),
+ unit = c.str("unit"), timestamp = c.long("timestamp"), sourceRaw = c.str("sourceRaw"),
+ confidenceRaw = c.str("confidenceRaw"), activitySessionId = c.strOrNull("activitySessionId"),
+ rawPacketId = c.strOrNull("rawPacketId"), createdAt = c.long("createdAt"),
+ )
+ },
+ activityDaily = collect("activity_daily") { c ->
+ ActivityDailyDTO(
+ id = c.str("id"), date = c.long("date"), steps = c.int_("steps"),
+ calories = c.dbl("calories"), distanceMeters = c.dbl("distanceMeters"),
+ activeMinutes = c.int_("activeMinutes"), source = c.str("source"),
+ syncedAt = c.longOrNull("syncedAt"), createdAt = c.long("createdAt"),
+ updatedAt = c.long("updatedAt"),
+ estimatedActiveCalories = c.dblOrNull("estimatedActiveCalories"),
+ )
+ },
+ activityBuckets = collect("activity_buckets") { c ->
+ ActivityBucketDTO(
+ startEpoch = c.long("startEpoch"), date = c.long("date"),
+ steps = c.int_("steps"), distanceMeters = c.dbl("distanceMeters"),
+ source = c.str("source"), updatedAt = c.long("updatedAt"),
+ )
+ },
+ batterySamples = collect("battery_samples") { c ->
+ BatterySampleDTO(
+ id = c.str("id"), percent = c.int_("percent"),
+ timestamp = c.long("timestamp"), createdAt = c.long("createdAt"),
+ )
+ },
+ deviceMeasurementConfigs = collect("device_measurement_configs") { c ->
+ DeviceMeasurementConfigDTO(
+ deviceId = c.str("deviceId"), hrIntervalMinutes = c.int_("hrIntervalMinutes"),
+ hrEnabled = c.bool("hrEnabled"), spo2Enabled = c.bool("spo2Enabled"),
+ stressEnabled = c.bool("stressEnabled"), hrvEnabled = c.bool("hrvEnabled"),
+ temperatureEnabled = c.bool("temperatureEnabled"), updatedAt = c.long("updatedAt"),
+ )
+ },
+ activitySessions = collect("activity_sessions") { c ->
+ ActivitySessionDTO(
+ id = c.str("id"), type = c.str("type"), statusRaw = c.str("statusRaw"),
+ startedAt = c.long("startedAt"), endedAt = c.longOrNull("endedAt"),
+ totalPauseSeconds = c.dbl("totalPauseSeconds"), calories = c.dblOrNull("calories"),
+ distanceMeters = c.dblOrNull("distanceMeters"), avgHeartRate = c.dblOrNull("avgHeartRate"),
+ minHeartRate = c.dblOrNull("minHeartRate"), maxHeartRate = c.dblOrNull("maxHeartRate"),
+ avgSpO2 = c.dblOrNull("avgSpO2"), latestSpO2 = c.dblOrNull("latestSpO2"),
+ notes = c.strOrNull("notes"), useGps = c.bool("useGps"),
+ perceivedEffort = c.strOrNull("perceivedEffort"), gpsPointCount = c.int_("gpsPointCount"),
+ rejectedGpsPointCount = c.int_("rejectedGpsPointCount"),
+ hrPollCount = c.int_("hrPollCount"), hrPollFailureCount = c.int_("hrPollFailureCount"),
+ spo2PollCount = c.int_("spo2PollCount"), spo2PollFailureCount = c.int_("spo2PollFailureCount"),
+ liveActivityID = c.strOrNull("liveActivityID"),
+ lastSensorPollAt = c.longOrNull("lastSensorPollAt"),
+ lastGpsPointAt = c.longOrNull("lastGpsPointAt"),
+ stravaActivityId = c.longOrNull("stravaActivityId"),
+ createdAt = c.long("createdAt"), updatedAt = c.long("updatedAt"),
+ )
+ },
+ activityGpsPoints = collect("activity_gps_points") { c ->
+ ActivityGpsPointDTO(
+ id = c.str("id"), sessionId = c.str("sessionId"),
+ latitude = c.dbl("latitude"), longitude = c.dbl("longitude"),
+ altitude = c.dblOrNull("altitude"), horizontalAccuracy = c.dblOrNull("horizontalAccuracy"),
+ speed = c.dblOrNull("speed"), course = c.dblOrNull("course"),
+ timestamp = c.long("timestamp"), accepted = c.bool("accepted"),
+ rejectionReason = c.strOrNull("rejectionReason"),
+ )
+ },
+ activityEvents = collect("activity_events") { c ->
+ ActivityEventDTO(
+ id = c.str("id"), sessionId = c.str("sessionId"), kind = c.str("kind"),
+ timestamp = c.long("timestamp"), payloadJSON = c.strOrNull("payloadJSON"),
+ )
+ },
+ activitySamples = collect("activity_samples") { c ->
+ ActivitySampleDTO(
+ id = c.str("id"), sessionId = c.str("sessionId"),
+ measurementId = c.strOrNull("measurementId"), kind = c.str("kind"),
+ value = c.dbl("value"), unit = c.str("unit"), timestamp = c.long("timestamp"),
+ source = c.str("source"), confidenceRaw = c.str("confidenceRaw"),
+ )
+ },
+ activitySensorPolls = collect("activity_sensor_polls") { c ->
+ ActivitySensorPollDTO(
+ id = c.str("id"), sessionId = c.str("sessionId"), timestamp = c.long("timestamp"),
+ kind = c.str("kind"), status = c.str("status"), value = c.dblOrNull("value"),
+ errorMessage = c.strOrNull("errorMessage"),
+ )
+ },
+ sleepSessions = collect("sleep_sessions") { c ->
+ SleepSessionDTO(
+ id = c.str("id"), date = c.long("date"), startAt = c.long("startAt"),
+ endAt = c.long("endAt"), totalMinutes = c.int_("totalMinutes"),
+ score = c.intOrNull("score"), syncedAt = c.longOrNull("syncedAt"),
+ sourceRaw = c.str("sourceRaw"), createdAt = c.long("createdAt"),
+ updatedAt = c.long("updatedAt"),
+ )
+ },
+ sleepStageBlocks = collect("sleep_stage_blocks") { c ->
+ SleepStageBlockDTO(
+ id = c.str("id"), sessionId = c.str("sessionId"), startAt = c.long("startAt"),
+ startMinute = c.int_("startMinute"), durationMinutes = c.int_("durationMinutes"),
+ stageRaw = c.str("stageRaw"),
+ )
+ },
+ coachConversations = collect("coach_conversations") { c ->
+ CoachConversationDTO(
+ id = c.str("id"), title = c.str("title"), createdAt = c.long("createdAt"),
+ updatedAt = c.long("updatedAt"), totalInputTokens = c.int_("totalInputTokens"),
+ totalOutputTokens = c.int_("totalOutputTokens"), totalCostUSD = c.dbl("totalCostUSD"),
+ )
+ },
+ coachMessages = collect("coach_messages") { c ->
+ CoachMessageDTO(
+ id = c.str("id"), conversationId = c.str("conversationId"),
+ role = c.str("role"), body = c.str("body"),
+ cardsJSON = c.strOrNull("cardsJSON"), pendingActionJSON = c.strOrNull("pendingActionJSON"),
+ attachmentsJson = c.strOrNull("attachmentsJson"), createdAt = c.long("createdAt"),
+ inputTokens = c.intOrNull("inputTokens"), outputTokens = c.intOrNull("outputTokens"),
+ costUSD = c.dblOrNull("costUSD"), modelUsed = c.strOrNull("modelUsed"),
+ providerUsed = c.strOrNull("providerUsed"),
+ )
+ },
+ coachMemories = collect("coach_memories") { c ->
+ CoachMemoryDTO(
+ id = c.str("id"), key = c.str("key"), value = c.str("value"),
+ memoryType = c.str("memoryType"), importance = c.int_("importance"),
+ expiresAt = c.longOrNull("expiresAt"), sourceMessageId = c.strOrNull("sourceMessageId"),
+ isUserEditable = c.bool("isUserEditable"), createdAt = c.long("createdAt"),
+ updatedAt = c.long("updatedAt"),
+ )
+ },
+ coachToolCalls = collect("coach_tool_calls") { c ->
+ CoachToolCallDTO(
+ id = c.str("id"), conversationId = c.str("conversationId"),
+ messageId = c.strOrNull("messageId"), toolName = c.str("toolName"),
+ inputJSON = c.strOrNull("inputJSON"), outputJSON = c.strOrNull("outputJSON"),
+ label = c.str("label"), statusRaw = c.str("statusRaw"),
+ sequence = c.int_("sequence"), createdAt = c.long("createdAt"),
+ )
+ },
+ userProfiles = collect("user_profiles") { c ->
+ UserProfileDTO(
+ id = c.str("id"), name = c.strOrNull("name"), age = c.intOrNull("age"),
+ sex = c.strOrNull("sex"), heightCm = c.dblOrNull("heightCm"),
+ weightKg = c.dblOrNull("weightKg"), onboardingCompleted = c.bool("onboardingCompleted"),
+ baselineCompleted = c.bool("baselineCompleted"),
+ hrZoneModeRaw = c.strOrNull("hrZoneModeRaw") ?: "auto",
+ hrRestingBaseline = c.dblOrNull("hrRestingBaseline"),
+ hrRestingBaselineUpdatedAt = c.longOrNull("hrRestingBaselineUpdatedAt"),
+ hrCustomLowUpper = c.dblOrNull("hrCustomLowUpper"),
+ hrCustomAthleticUpper = c.dblOrNull("hrCustomAthleticUpper"),
+ hrCustomElevatedStart = c.dblOrNull("hrCustomElevatedStart"),
+ hrCustomHighStart = c.dblOrNull("hrCustomHighStart"),
+ createdAt = c.long("createdAt"), updatedAt = c.long("updatedAt"),
+ )
+ },
+ userGoals = collect("user_goals") { c ->
+ UserGoalDTO(
+ id = c.str("id"), steps = c.int_("steps"),
+ distanceMeters = c.dbl("distanceMeters"), calories = c.int_("calories"),
+ sleepMinutes = c.int_("sleepMinutes"), activeMinutes = c.int_("activeMinutes"),
+ workoutsPerWeek = c.int_("workoutsPerWeek"),
+ intakeCalories = c.dblOrNull("intakeCalories"),
+ intakeProteinG = c.dblOrNull("intakeProteinG"),
+ intakeCarbsG = c.dblOrNull("intakeCarbsG"),
+ intakeFatG = c.dblOrNull("intakeFatG"),
+ nutritionEnabled = c.bool("nutritionEnabled"),
+ updatedAt = c.long("updatedAt"),
+ )
+ },
+ rawPackets = collect("raw_packets") { c ->
+ RawPacketDTO(
+ id = c.str("id"), timestamp = c.long("timestamp"),
+ directionRaw = c.str("directionRaw"), commandId = c.int_("commandId"),
+ hexPayload = c.str("hexPayload"), decodedKind = c.strOrNull("decodedKind"),
+ decodedJSON = c.strOrNull("decodedJSON"), confidenceRaw = c.str("confidenceRaw"),
+ createdAt = c.long("createdAt"),
+ )
+ },
+ derivedUpdates = collect("derived_updates") { c ->
+ DerivedUpdateDTO(
+ id = c.str("id"), timestamp = c.long("timestamp"), kind = c.str("kind"),
+ entityType = c.str("entityType"), entityId = c.str("entityId"),
+ payloadJSON = c.strOrNull("payloadJSON"),
+ )
+ },
+ coachSummaries = collect("coach_summaries") { c ->
+ CoachSummaryDTO(
+ id = c.str("id"), kind = c.str("kind"), scopeKey = c.str("scopeKey"),
+ title = c.str("title"), body = c.str("body"),
+ chipsJSON = c.strOrNull("chipsJSON"), conversationId = c.strOrNull("conversationId"),
+ dataSignature = c.str("dataSignature"), createdAt = c.long("createdAt"),
+ updatedAt = c.long("updatedAt"),
+ )
+ },
+ wearableLogs = collect("wearable_logs") { c ->
+ // `event` is the raw message. It used to be packed as "CATEGORY/LEVEL: message",
+ // which the importer then assigned straight back to `message` — so every
+ // export→import cycle prefixed the text again. Category and level travel in their
+ // own fields.
+ WearableLogDTO(
+ id = c.str("id"), timestamp = c.long("timestamp"),
+ event = c.str("message"),
+ detail = c.strOrNull("metadataJSON"),
+ deviceId = c.strOrNull("deviceTypeRaw"),
+ categoryRaw = c.str("categoryRaw"),
+ levelRaw = c.str("levelRaw"),
+ )
+ },
+ coachNotificationRecords = collect("coach_notification_records") { c ->
+ CoachNotificationRecordDTO(
+ id = c.str("id"), title = c.str("title"), body = c.str("body"),
+ createdAt = c.long("createdAt"),
+ )
+ },
+ mealEntries = collect("meal_entries") { c ->
+ MealEntryDTO(
+ id = c.str("id"), date = c.long("date"), timestamp = c.long("timestamp"),
+ name = c.str("name"), mealTypeRaw = c.str("mealTypeRaw"),
+ calories = c.dbl("calories"), proteinG = c.dbl("proteinG"),
+ carbsG = c.dbl("carbsG"), fatG = c.dbl("fatG"),
+ fiberG = c.dblOrNull("fiberG"), sugarG = c.dblOrNull("sugarG"),
+ sodiumMg = c.dblOrNull("sodiumMg"), sourceRaw = c.str("sourceRaw"),
+ offProductCode = c.strOrNull("offProductCode"),
+ servingDescription = c.strOrNull("servingDescription"),
+ servingGrams = c.dblOrNull("servingGrams"), quantity = c.dbl("quantity"),
+ confidenceRaw = c.str("confidenceRaw"), userEdited = c.bool("userEdited"),
+ notes = c.strOrNull("notes"), loggedByCoach = c.bool("loggedByCoach"),
+ createdAt = c.long("createdAt"),
+ )
+ },
+ foodProducts = collect("food_products") { c ->
+ CachedFoodProductDTO(
+ code = c.str("code"), name = c.str("name"), brand = c.strOrNull("brand"),
+ energyKcal100g = c.dbl("energyKcal100g"), protein100g = c.dbl("protein100g"),
+ carbs100g = c.dbl("carbs100g"), fat100g = c.dbl("fat100g"),
+ fiber100g = c.dblOrNull("fiber100g"), sugars100g = c.dblOrNull("sugars100g"),
+ saturatedFat100g = c.dblOrNull("saturatedFat100g"),
+ sodiumMg100g = c.dblOrNull("sodiumMg100g"),
+ servingSizeText = c.strOrNull("servingSizeText"),
+ servingQuantityG = c.dblOrNull("servingQuantityG"),
+ lastUsedAt = c.long("lastUsedAt"), useCount = c.int_("useCount"),
+ )
+ },
+ )
+ }
+
+ suspend fun exportToFile(context: Context, db: PulseLoopDatabase): Uri? = withContext(Dispatchers.Default) {
+ val archive = exportArchive(db)
+ val jsonStr = json.encodeToString(PulseArchive.serializer(), archive)
+ val name = "pulseloop-export-${dateFmt.format(Date())}.json"
+ val file = java.io.File(context.cacheDir, name)
+ file.writeText(jsonStr)
+ androidx.core.content.FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
+ }
+
+ suspend fun importFile(context: Context, uri: Uri, db: PulseLoopDatabase): PulseArchive = withContext(Dispatchers.Default) {
+ val stream = context.contentResolver.openInputStream(uri)
+ ?: throw java.io.IOException("Cannot open file")
+ val jsonStr = stream.bufferedReader().use { it.readText() }
+ val archive = json.decodeFromString(PulseArchive.serializer(), jsonStr)
+
+ // One transaction for the whole restore, so a process kill mid-import leaves the original
+ // database intact rather than permanently empty.
+ //
+ // This MUST go through Room's `withTransaction`, not `openHelper.writableDatabase
+ // .beginTransaction()`. A framework SQLite transaction is bound to the thread that opened
+ // it, while a suspend DAO call hops to Room's query dispatcher — so a raw transaction here
+ // would leave every `db.xxxDao()` call below blocking on a write connection held by this
+ // (suspended) thread. `withTransaction` installs a TransactionElement that keeps those DAO
+ // calls on the transaction's own dispatcher. It also routes through
+ // `RoomDatabase.endTransaction()`, which is what fires the invalidation tracker — a raw
+ // transaction leaves every observing Flow stale after the restore.
+ db.withTransaction {
+ // Deletes go through the same transaction connection, so Room's invalidation triggers
+ // still fire for them. Parent-before-child ordering means the CASCADE foreign keys
+ // (sleep_stage_blocks, coach_messages) resolve on their own — the previous
+ // `PRAGMA foreign_keys = OFF` was a no-op anyway, since SQLite refuses to toggle FK
+ // enforcement inside a transaction.
+ for (table in PulseLoopDatabase.ALL_TABLES) {
+ db.openHelper.writableDatabase.execSQL("DELETE FROM $table")
+ }
+
+ for (d in archive.devices) {
+ db.deviceDao().upsert(DeviceEntity(
+ id = d.id, name = d.name, advertisedName = d.advertisedName,
+ peripheralIdentifier = d.peripheralIdentifier, bleAddressHint = d.bleAddressHint,
+ batteryPercent = d.batteryPercent, stateRaw = d.stateRaw,
+ deviceTypeRaw = d.deviceTypeRaw, wearableModelID = d.wearableModelID,
+ capabilitiesRaw = d.capabilitiesRaw, lastConnectedAt = d.lastConnectedAt,
+ lastDisconnectedAt = d.lastDisconnectedAt, lastSyncAt = d.lastSyncAt,
+ lastFullSyncAt = d.lastFullSyncAt, firmwareVersion = d.firmwareVersion,
+ createdAt = d.createdAt, updatedAt = d.updatedAt,
+ ))
+ }
+ for (m in archive.measurements) {
+ db.measurementDao().insert(MeasurementEntity(
+ id = m.id, kindRaw = m.kindRaw, value = m.value, unit = m.unit,
+ timestamp = m.timestamp, sourceRaw = m.sourceRaw,
+ confidenceRaw = m.confidenceRaw, activitySessionId = m.activitySessionId,
+ rawPacketId = m.rawPacketId, createdAt = m.createdAt,
+ ))
+ }
+ for (a in archive.activityDaily) {
+ db.activityDailyDao().upsert(ActivityDailyEntity(
+ id = a.id, date = a.date, steps = a.steps, calories = a.calories,
+ distanceMeters = a.distanceMeters, activeMinutes = a.activeMinutes,
+ source = a.source, syncedAt = a.syncedAt, createdAt = a.createdAt,
+ updatedAt = a.updatedAt,
+ estimatedActiveCalories = a.estimatedActiveCalories,
+ ))
+ }
+ for (b in archive.activityBuckets) {
+ db.activityBucketDao().upsert(ActivityBucketEntity(
+ startEpoch = b.startEpoch, date = b.date, steps = b.steps,
+ distanceMeters = b.distanceMeters, source = b.source, updatedAt = b.updatedAt,
+ ))
+ }
+ for (bs in archive.batterySamples) {
+ db.batterySampleDao().insert(BatterySampleEntity(
+ id = bs.id, percent = bs.percent, timestamp = bs.timestamp,
+ createdAt = bs.createdAt,
+ ))
+ }
+ for (dmc in archive.deviceMeasurementConfigs) {
+ db.deviceMeasurementConfigDao().upsert(DeviceMeasurementConfigEntity(
+ deviceId = dmc.deviceId, hrIntervalMinutes = dmc.hrIntervalMinutes,
+ hrEnabled = dmc.hrEnabled, spo2Enabled = dmc.spo2Enabled,
+ stressEnabled = dmc.stressEnabled, hrvEnabled = dmc.hrvEnabled,
+ temperatureEnabled = dmc.temperatureEnabled, updatedAt = dmc.updatedAt,
+ ))
+ }
+ for (as_ in archive.activitySessions) {
+ db.activitySessionDao().upsert(ActivitySessionEntity(
+ id = as_.id, type = as_.type, statusRaw = as_.statusRaw,
+ startedAt = as_.startedAt, endedAt = as_.endedAt,
+ totalPauseSeconds = as_.totalPauseSeconds, calories = as_.calories,
+ distanceMeters = as_.distanceMeters, avgHeartRate = as_.avgHeartRate,
+ minHeartRate = as_.minHeartRate, maxHeartRate = as_.maxHeartRate,
+ avgSpO2 = as_.avgSpO2, latestSpO2 = as_.latestSpO2,
+ notes = as_.notes, useGps = as_.useGps,
+ perceivedEffort = as_.perceivedEffort, gpsPointCount = as_.gpsPointCount,
+ rejectedGpsPointCount = as_.rejectedGpsPointCount,
+ hrPollCount = as_.hrPollCount, hrPollFailureCount = as_.hrPollFailureCount,
+ spo2PollCount = as_.spo2PollCount, spo2PollFailureCount = as_.spo2PollFailureCount,
+ liveActivityID = as_.liveActivityID, lastSensorPollAt = as_.lastSensorPollAt,
+ lastGpsPointAt = as_.lastGpsPointAt, stravaActivityId = as_.stravaActivityId,
+ createdAt = as_.createdAt, updatedAt = as_.updatedAt,
+ ))
+ }
+ for (gp in archive.activityGpsPoints) {
+ db.activityGpsPointDao().insert(ActivityGpsPointEntity(
+ id = gp.id, sessionId = gp.sessionId, latitude = gp.latitude,
+ longitude = gp.longitude, altitude = gp.altitude,
+ horizontalAccuracy = gp.horizontalAccuracy, speed = gp.speed,
+ course = gp.course, timestamp = gp.timestamp, accepted = gp.accepted,
+ rejectionReason = gp.rejectionReason,
+ ))
+ }
+ for (ev in archive.activityEvents) {
+ insertRaw(db, "activity_events", ContentValues().apply {
+ put("id", ev.id); put("sessionId", ev.sessionId); put("kind", ev.kind)
+ put("timestamp", ev.timestamp); ev.payloadJSON?.let { put("payloadJSON", it) }
+ })
+ }
+ for (samp in archive.activitySamples) {
+ insertRaw(db, "activity_samples", ContentValues().apply {
+ put("id", samp.id); put("sessionId", samp.sessionId)
+ samp.measurementId?.let { put("measurementId", it) }
+ put("kind", samp.kind); put("value", samp.value); put("unit", samp.unit)
+ put("timestamp", samp.timestamp); put("source", samp.source)
+ put("confidenceRaw", samp.confidenceRaw)
+ })
+ }
+ for (sp in archive.activitySensorPolls) {
+ insertRaw(db, "activity_sensor_polls", ContentValues().apply {
+ put("id", sp.id); put("sessionId", sp.sessionId)
+ put("timestamp", sp.timestamp); put("kind", sp.kind)
+ put("status", sp.status); sp.value?.let { put("value", it) }
+ sp.errorMessage?.let { put("errorMessage", it) }
+ })
+ }
+ for (ss in archive.sleepSessions) {
+ db.sleepSessionDao().upsert(SleepSessionEntity(
+ id = ss.id, date = ss.date, startAt = ss.startAt, endAt = ss.endAt,
+ totalMinutes = ss.totalMinutes, score = ss.score, syncedAt = ss.syncedAt,
+ sourceRaw = ss.sourceRaw, createdAt = ss.createdAt, updatedAt = ss.updatedAt,
+ ))
+ }
+ for (block in archive.sleepStageBlocks) {
+ db.sleepStageBlockDao().insert(SleepStageBlockEntity(
+ id = block.id, sessionId = block.sessionId, startAt = block.startAt,
+ startMinute = block.startMinute, durationMinutes = block.durationMinutes,
+ stageRaw = block.stageRaw,
+ ))
+ }
+ for (conv in archive.coachConversations) {
+ db.coachConversationDao().upsert(CoachConversationEntity(
+ id = conv.id, title = conv.title, createdAt = conv.createdAt,
+ updatedAt = conv.updatedAt, totalInputTokens = conv.totalInputTokens,
+ totalOutputTokens = conv.totalOutputTokens, totalCostUSD = conv.totalCostUSD,
+ ))
+ }
+ for (msg in archive.coachMessages) {
+ db.coachMessageDao().insert(CoachMessageEntity(
+ id = msg.id, conversationId = msg.conversationId, role = msg.role,
+ body = msg.body, cardsJSON = msg.cardsJSON,
+ pendingActionJSON = msg.pendingActionJSON,
+ attachmentsJson = msg.attachmentsJson, createdAt = msg.createdAt,
+ inputTokens = msg.inputTokens, outputTokens = msg.outputTokens,
+ costUSD = msg.costUSD, modelUsed = msg.modelUsed,
+ providerUsed = msg.providerUsed,
+ ))
+ }
+ for (mem in archive.coachMemories) {
+ db.coachMemoryDao().upsert(CoachMemoryEntity(
+ id = mem.id, key = mem.key, value = mem.value,
+ memoryType = mem.memoryType, importance = mem.importance,
+ expiresAt = mem.expiresAt, sourceMessageId = mem.sourceMessageId,
+ isUserEditable = mem.isUserEditable, createdAt = mem.createdAt,
+ updatedAt = mem.updatedAt,
+ ))
+ }
+ for (tc in archive.coachToolCalls) {
+ db.coachToolCallDao().insert(CoachToolCallEntity(
+ id = tc.id, conversationId = tc.conversationId,
+ messageId = tc.messageId, toolName = tc.toolName,
+ inputJSON = tc.inputJSON, outputJSON = tc.outputJSON,
+ label = tc.label, statusRaw = tc.statusRaw,
+ sequence = tc.sequence, createdAt = tc.createdAt,
+ ))
+ }
+ for (up in archive.userProfiles) {
+ db.userProfileDao().upsert(UserProfileEntity(
+ id = up.id, name = up.name, age = up.age, sex = up.sex,
+ heightCm = up.heightCm, weightKg = up.weightKg,
+ onboardingCompleted = up.onboardingCompleted,
+ baselineCompleted = up.baselineCompleted,
+ hrZoneModeRaw = up.hrZoneModeRaw,
+ hrRestingBaseline = up.hrRestingBaseline,
+ hrRestingBaselineUpdatedAt = up.hrRestingBaselineUpdatedAt,
+ hrCustomLowUpper = up.hrCustomLowUpper,
+ hrCustomAthleticUpper = up.hrCustomAthleticUpper,
+ hrCustomElevatedStart = up.hrCustomElevatedStart,
+ hrCustomHighStart = up.hrCustomHighStart,
+ createdAt = up.createdAt, updatedAt = up.updatedAt,
+ ))
+ }
+ for (ug in archive.userGoals) {
+ db.userGoalDao().upsert(UserGoalEntity(
+ id = ug.id, steps = ug.steps, distanceMeters = ug.distanceMeters,
+ calories = ug.calories, sleepMinutes = ug.sleepMinutes,
+ activeMinutes = ug.activeMinutes, workoutsPerWeek = ug.workoutsPerWeek,
+ intakeCalories = ug.intakeCalories, intakeProteinG = ug.intakeProteinG,
+ intakeCarbsG = ug.intakeCarbsG, intakeFatG = ug.intakeFatG,
+ nutritionEnabled = ug.nutritionEnabled,
+ updatedAt = ug.updatedAt,
+ ))
+ }
+ for (rp in archive.rawPackets) {
+ db.rawPacketDao().insert(RawPacketEntity(
+ id = rp.id, timestamp = rp.timestamp, directionRaw = rp.directionRaw,
+ commandId = rp.commandId, hexPayload = rp.hexPayload,
+ decodedKind = rp.decodedKind, decodedJSON = rp.decodedJSON,
+ confidenceRaw = rp.confidenceRaw, createdAt = rp.createdAt,
+ ))
+ }
+ for (du in archive.derivedUpdates) {
+ insertRaw(db, "derived_updates", ContentValues().apply {
+ put("id", du.id); put("timestamp", du.timestamp); put("kind", du.kind)
+ put("entityType", du.entityType); put("entityId", du.entityId)
+ du.payloadJSON?.let { put("payloadJSON", it) }
+ })
+ }
+ for (cs in archive.coachSummaries) {
+ db.coachSummaryDao().upsert(CoachSummaryEntity(
+ id = cs.id, kind = cs.kind, scopeKey = cs.scopeKey,
+ title = cs.title, body = cs.body, chipsJSON = cs.chipsJSON,
+ conversationId = cs.conversationId, dataSignature = cs.dataSignature,
+ createdAt = cs.createdAt, updatedAt = cs.updatedAt,
+ ))
+ }
+ for (wl in archive.wearableLogs) {
+ db.wearableLogDao().insert(WearableLogEntity(
+ id = wl.id, timestamp = wl.timestamp,
+ categoryRaw = wl.categoryRaw ?: "CONNECTION",
+ levelRaw = wl.levelRaw ?: "INFO",
+ message = wl.event, metadataJSON = wl.detail,
+ deviceTypeRaw = wl.deviceId ?: "",
+ ))
+ }
+ for (nr in archive.coachNotificationRecords) {
+ db.coachNotificationRecordDao().insert(CoachNotificationRecordEntity(
+ id = nr.id, title = nr.title, body = nr.body, createdAt = nr.createdAt,
+ ))
+ }
+ for (m in archive.mealEntries) {
+ db.mealEntryDao().upsert(MealEntryEntity(
+ id = m.id, date = m.date, timestamp = m.timestamp, name = m.name,
+ mealTypeRaw = m.mealTypeRaw, calories = m.calories, proteinG = m.proteinG,
+ carbsG = m.carbsG, fatG = m.fatG, fiberG = m.fiberG, sugarG = m.sugarG,
+ sodiumMg = m.sodiumMg, sourceRaw = m.sourceRaw, offProductCode = m.offProductCode,
+ servingDescription = m.servingDescription, servingGrams = m.servingGrams,
+ quantity = m.quantity, confidenceRaw = m.confidenceRaw, userEdited = m.userEdited,
+ notes = m.notes, loggedByCoach = m.loggedByCoach, createdAt = m.createdAt,
+ ))
+ }
+ for (fp in archive.foodProducts) {
+ db.foodProductDao().upsert(CachedFoodProductEntity(
+ code = fp.code, name = fp.name, brand = fp.brand,
+ energyKcal100g = fp.energyKcal100g, protein100g = fp.protein100g,
+ carbs100g = fp.carbs100g, fat100g = fp.fat100g, fiber100g = fp.fiber100g,
+ sugars100g = fp.sugars100g, saturatedFat100g = fp.saturatedFat100g,
+ sodiumMg100g = fp.sodiumMg100g, servingSizeText = fp.servingSizeText,
+ servingQuantityG = fp.servingQuantityG, lastUsedAt = fp.lastUsedAt,
+ useCount = fp.useCount,
+ ))
+ }
+ }
+
+ archive
+ }
+
+
+ // --- Cursor helpers ---
+
+ private fun Cursor.str(col: String): String =
+ getString(getColumnIndexOrThrow(col))
+
+ private fun Cursor.strOrNull(col: String): String? {
+ val idx = getColumnIndex(col)
+ return if (idx < 0) null else getString(idx)
+ }
+
+ private fun Cursor.int_(col: String): Int =
+ getInt(getColumnIndexOrThrow(col))
+
+ private fun Cursor.intOrNull(col: String): Int? {
+ val idx = getColumnIndex(col)
+ return if (idx < 0 || isNull(idx)) null else getInt(idx)
+ }
+
+ private fun Cursor.long(col: String): Long =
+ getLong(getColumnIndexOrThrow(col))
+
+ private fun Cursor.longOrNull(col: String): Long? {
+ val idx = getColumnIndex(col)
+ return if (idx < 0 || isNull(idx)) null else getLong(idx)
+ }
+
+ private fun Cursor.dbl(col: String): Double =
+ getDouble(getColumnIndexOrThrow(col))
+
+ private fun Cursor.dblOrNull(col: String): Double? {
+ val idx = getColumnIndex(col)
+ return if (idx < 0 || isNull(idx)) null else getDouble(idx)
+ }
+
+ private fun Cursor.bool(col: String): Boolean =
+ getInt(getColumnIndexOrThrow(col)) != 0
+}
diff --git a/app/src/main/java/com/pulseloop/data/PulseLoopDatabase.kt b/app/src/main/java/com/pulseloop/data/PulseLoopDatabase.kt
index 7ded19a..72e13a9 100644
--- a/app/src/main/java/com/pulseloop/data/PulseLoopDatabase.kt
+++ b/app/src/main/java/com/pulseloop/data/PulseLoopDatabase.kt
@@ -5,6 +5,7 @@ import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.migration.Migration
+import androidx.room.withTransaction
import androidx.sqlite.db.SupportSQLiteDatabase
import com.pulseloop.data.dao.*
import com.pulseloop.data.entity.*
@@ -39,8 +40,10 @@ import com.pulseloop.data.entity.*
WearableLogEntity::class,
BatterySampleEntity::class,
CoachNotificationRecordEntity::class,
+ MealEntryEntity::class,
+ CachedFoodProductEntity::class,
],
- version = 16,
+ version = 20,
exportSchema = false,
)
abstract class PulseLoopDatabase : RoomDatabase() {
@@ -64,8 +67,46 @@ abstract class PulseLoopDatabase : RoomDatabase() {
abstract fun rawPacketDao(): RawPacketDao
abstract fun batterySampleDao(): BatterySampleDao
abstract fun coachNotificationRecordDao(): CoachNotificationRecordDao
+ // iOS #96: Nutrition
+ abstract fun mealEntryDao(): MealEntryDao
+ abstract fun foodProductDao(): FoodProductDao
+
+ /**
+ * Empty every table, atomically. Goes through Room's [withTransaction] rather than a raw
+ * `openHelper.writableDatabase.beginTransaction()`: only the Room path runs
+ * `RoomDatabase.endTransaction()`, which is what kicks the invalidation tracker — without it
+ * every observing Flow keeps serving the rows we just deleted until something else writes.
+ *
+ * Not [clearAllTables]: that also drops the sqlite_sequence rows and checkpoints the WAL, and
+ * it throws if called while a transaction is open — which is exactly how `DataArchiveService`
+ * uses this list during a restore.
+ */
+ suspend fun nukeAllTables() = withTransaction {
+ for (table in ALL_TABLES) {
+ openHelper.writableDatabase.execSQL("DELETE FROM $table")
+ }
+ }
companion object {
+ /**
+ * Every table in the schema, in parent-before-child order so the CASCADE foreign keys
+ * (sleep_stage_blocks → sleep_sessions, coach_messages → coach_conversations) resolve on
+ * their own. Shared by [nukeAllTables] and `DataArchiveService`'s restore so the two can
+ * never drift — they were separate literals before, and the restore list was already two
+ * tables short.
+ */
+ val ALL_TABLES = listOf(
+ "devices", "measurements", "activity_daily", "activity_buckets",
+ "battery_samples", "device_measurement_configs", "activity_sessions",
+ "activity_gps_points", "activity_events", "activity_samples",
+ "activity_sensor_polls", "sleep_sessions", "sleep_stage_blocks",
+ "coach_conversations", "coach_messages", "coach_memories",
+ "coach_tool_calls", "user_profiles", "user_goals",
+ "raw_packets", "derived_updates", "coach_summaries",
+ "wearable_logs", "coach_notification_records",
+ "meal_entries", "food_products",
+ )
+
@Volatile private var INSTANCE: PulseLoopDatabase? = null
/** v2 → v3: adds the activity_buckets table (idempotent re-sync of activity history). */
@@ -289,6 +330,67 @@ abstract class PulseLoopDatabase : RoomDatabase() {
}
}
+ private val MIGRATION_16_17 = object : Migration(16, 17) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ db.execSQL("ALTER TABLE `activity_daily` ADD COLUMN `estimatedActiveCalories` REAL")
+ }
+ }
+
+ private val MIGRATION_17_18 = object : Migration(17, 18) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ db.execSQL("ALTER TABLE `user_profiles` ADD COLUMN `hrZoneModeRaw` TEXT NOT NULL DEFAULT 'auto'")
+ db.execSQL("ALTER TABLE `user_profiles` ADD COLUMN `hrRestingBaseline` REAL")
+ db.execSQL("ALTER TABLE `user_profiles` ADD COLUMN `hrRestingBaselineUpdatedAt` INTEGER")
+ db.execSQL("ALTER TABLE `user_profiles` ADD COLUMN `hrCustomLowUpper` REAL")
+ db.execSQL("ALTER TABLE `user_profiles` ADD COLUMN `hrCustomAthleticUpper` REAL")
+ db.execSQL("ALTER TABLE `user_profiles` ADD COLUMN `hrCustomElevatedStart` REAL")
+ db.execSQL("ALTER TABLE `user_profiles` ADD COLUMN `hrCustomHighStart` REAL")
+ }
+ }
+
+ private val MIGRATION_18_19 = object : Migration(18, 19) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ db.execSQL("ALTER TABLE `activity_sessions` ADD COLUMN `stravaActivityId` INTEGER")
+ }
+ }
+
+ private val MIGRATION_19_20 = object : Migration(19, 20) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ db.execSQL("ALTER TABLE `user_goals` ADD COLUMN `intakeCalories` REAL")
+ db.execSQL("ALTER TABLE `user_goals` ADD COLUMN `intakeProteinG` REAL")
+ db.execSQL("ALTER TABLE `user_goals` ADD COLUMN `intakeCarbsG` REAL")
+ db.execSQL("ALTER TABLE `user_goals` ADD COLUMN `intakeFatG` REAL")
+ db.execSQL("ALTER TABLE `user_goals` ADD COLUMN `nutritionEnabled` INTEGER NOT NULL DEFAULT 0")
+ db.execSQL("""
+ CREATE TABLE IF NOT EXISTS `meal_entries` (
+ `id` TEXT NOT NULL, `date` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL,
+ `name` TEXT NOT NULL, `mealTypeRaw` TEXT NOT NULL DEFAULT 'snack',
+ `calories` REAL NOT NULL, `proteinG` REAL NOT NULL DEFAULT 0,
+ `carbsG` REAL NOT NULL DEFAULT 0, `fatG` REAL NOT NULL DEFAULT 0,
+ `fiberG` REAL, `sugarG` REAL, `sodiumMg` REAL,
+ `sourceRaw` TEXT NOT NULL DEFAULT 'manual', `offProductCode` TEXT,
+ `servingDescription` TEXT, `servingGrams` REAL, `quantity` REAL NOT NULL DEFAULT 1,
+ `confidenceRaw` TEXT NOT NULL DEFAULT 'medium', `userEdited` INTEGER NOT NULL DEFAULT 0,
+ `notes` TEXT, `loggedByCoach` INTEGER NOT NULL DEFAULT 0,
+ `createdAt` INTEGER NOT NULL, PRIMARY KEY(`id`)
+ )
+ """.trimIndent())
+ db.execSQL("CREATE INDEX IF NOT EXISTS `index_meal_entries_date` ON `meal_entries` (`date`)")
+ db.execSQL("""
+ CREATE TABLE IF NOT EXISTS `food_products` (
+ `code` TEXT NOT NULL, `name` TEXT NOT NULL, `brand` TEXT,
+ `energyKcal100g` REAL NOT NULL, `protein100g` REAL NOT NULL DEFAULT 0,
+ `carbs100g` REAL NOT NULL DEFAULT 0, `fat100g` REAL NOT NULL DEFAULT 0,
+ `fiber100g` REAL, `sugars100g` REAL, `saturatedFat100g` REAL,
+ `sodiumMg100g` REAL, `servingSizeText` TEXT, `servingQuantityG` REAL,
+ `lastUsedAt` INTEGER NOT NULL, `useCount` INTEGER NOT NULL DEFAULT 0,
+ PRIMARY KEY(`code`)
+ )
+ """.trimIndent())
+ db.execSQL("CREATE INDEX IF NOT EXISTS `index_food_products_lastUsedAt` ON `food_products` (`lastUsedAt`)")
+ }
+ }
+
private fun adoptStableMeasurementIdentities(db: SupportSQLiteDatabase) {
db.execSQL("DROP INDEX IF EXISTS `index_measurements_kindRaw_timestamp_sourceRaw`")
db.execSQL(
@@ -371,6 +473,10 @@ abstract class PulseLoopDatabase : RoomDatabase() {
MIGRATION_13_14,
MIGRATION_14_15,
MIGRATION_15_16,
+ MIGRATION_16_17,
+ MIGRATION_17_18,
+ MIGRATION_18_19,
+ MIGRATION_19_20,
)
// Downgrades only (sideloading an older APK). A blanket destructive
// fallback would silently wipe every measurement, sleep session, and
diff --git a/app/src/main/java/com/pulseloop/data/dao/Daos.kt b/app/src/main/java/com/pulseloop/data/dao/Daos.kt
index e31f124..bf9c40d 100644
--- a/app/src/main/java/com/pulseloop/data/dao/Daos.kt
+++ b/app/src/main/java/com/pulseloop/data/dao/Daos.kt
@@ -394,10 +394,56 @@ interface CoachNotificationRecordDao {
@Insert
suspend fun insert(record: CoachNotificationRecordEntity)
- /** Most recent delivered check-ins, newest first — anti-repeat prompt hint (iOS #65). */
@Query("SELECT * FROM coach_notification_records ORDER BY createdAt DESC LIMIT :limit")
suspend fun recent(limit: Int = 6): List
@Query("DELETE FROM coach_notification_records")
suspend fun clear()
}
+
+// ── Nutrition (iOS #96) ──────────────────────────────────────────────────────────
+
+@Dao
+interface MealEntryDao {
+ @Query("SELECT * FROM meal_entries WHERE date = :day ORDER BY timestamp ASC")
+ suspend fun byDay(day: Long): List
+
+ @Query("""
+ SELECT mealTypeRaw, SUM(calories) as totalCal, SUM(proteinG) as totalP,
+ SUM(carbsG) as totalC, SUM(fatG) as totalF
+ FROM meal_entries WHERE date = :day GROUP BY mealTypeRaw
+ """)
+ suspend fun dayTotals(day: Long): List
+
+ @Upsert
+ suspend fun upsert(entry: MealEntryEntity)
+
+ @Query("DELETE FROM meal_entries WHERE id = :id")
+ suspend fun deleteById(id: String)
+
+ @Query("DELETE FROM meal_entries")
+ suspend fun clear()
+}
+
+data class MealTotals(
+ val mealTypeRaw: String, val totalCal: Double, val totalP: Double,
+ val totalC: Double, val totalF: Double,
+)
+
+@Dao
+interface FoodProductDao {
+ @Query("SELECT * FROM food_products WHERE code = :code LIMIT 1")
+ suspend fun byCode(code: String): CachedFoodProductEntity?
+
+ @Query("SELECT * FROM food_products ORDER BY lastUsedAt DESC LIMIT :limit")
+ suspend fun recent(limit: Int = 20): List
+
+ @Query("SELECT * FROM food_products WHERE name LIKE '%' || :q || '%' ORDER BY useCount DESC LIMIT :limit")
+ suspend fun search(q: String, limit: Int = 10): List
+
+ @Upsert
+ suspend fun upsert(product: CachedFoodProductEntity)
+
+ @Query("DELETE FROM food_products")
+ suspend fun clear()
+}
diff --git a/app/src/main/java/com/pulseloop/data/entity/CoreEntities.kt b/app/src/main/java/com/pulseloop/data/entity/CoreEntities.kt
index f33a2b9..2290f53 100644
--- a/app/src/main/java/com/pulseloop/data/entity/CoreEntities.kt
+++ b/app/src/main/java/com/pulseloop/data/entity/CoreEntities.kt
@@ -84,6 +84,9 @@ data class ActivityDailyEntity(
val syncedAt: Long? = null,
val createdAt: Long = System.currentTimeMillis(),
val updatedAt: Long = System.currentTimeMillis(),
+ /** Ported from iOS #98: net active calories estimated from all-day HR + step buckets when the
+ * ring does not report device-side calories. Read through [effectiveActiveCalories]. */
+ val estimatedActiveCalories: Double? = null,
)
/**
@@ -174,6 +177,8 @@ data class ActivitySessionEntity(
val liveActivityID: String? = null,
val lastSensorPollAt: Long? = null,
val lastGpsPointAt: Long? = null,
+ /** iOS #100: Strava activity ID after successful upload. null = not uploaded yet. */
+ val stravaActivityId: Long? = null,
val createdAt: Long = System.currentTimeMillis(),
val updatedAt: Long = System.currentTimeMillis(),
)
@@ -211,6 +216,16 @@ data class UserProfileEntity(
val baselineCompleted: Boolean = false,
val createdAt: Long = System.currentTimeMillis(),
val updatedAt: Long = System.currentTimeMillis(),
+ /** iOS #95: HR zone mode — "standard", "auto" (from baseline), or "custom". Default "auto". */
+ val hrZoneModeRaw: String = "auto",
+ /** iOS #95: Resting-HR baseline learned from 30-day p10. */
+ val hrRestingBaseline: Double? = null,
+ val hrRestingBaselineUpdatedAt: Long? = null,
+ /** iOS #95: Custom HR zone boundaries (only used when hrZoneModeRaw == "custom"). */
+ val hrCustomLowUpper: Double? = null,
+ val hrCustomAthleticUpper: Double? = null,
+ val hrCustomElevatedStart: Double? = null,
+ val hrCustomHighStart: Double? = null,
)
/**
@@ -227,6 +242,12 @@ data class UserGoalEntity(
val sleepMinutes: Int = 480,
val activeMinutes: Int = 45,
val workoutsPerWeek: Int = 4,
+ /** iOS #96: Nutrition intake goals + master toggle. */
+ val intakeCalories: Double? = null,
+ val intakeProteinG: Double? = null,
+ val intakeCarbsG: Double? = null,
+ val intakeFatG: Double? = null,
+ val nutritionEnabled: Boolean = false,
val updatedAt: Long = System.currentTimeMillis(),
) {
companion object {
diff --git a/app/src/main/java/com/pulseloop/data/entity/NutritionEntities.kt b/app/src/main/java/com/pulseloop/data/entity/NutritionEntities.kt
new file mode 100644
index 0000000..f762b21
--- /dev/null
+++ b/app/src/main/java/com/pulseloop/data/entity/NutritionEntities.kt
@@ -0,0 +1,48 @@
+package com.pulseloop.data.entity
+
+import androidx.room.*
+
+@Entity(tableName = "meal_entries", indices = [Index("date")])
+data class MealEntryEntity(
+ @PrimaryKey val id: String = java.util.UUID.randomUUID().toString(),
+ val date: Long,
+ val timestamp: Long,
+ val name: String,
+ val mealTypeRaw: String = "snack",
+ val calories: Double,
+ val proteinG: Double = 0.0,
+ val carbsG: Double = 0.0,
+ val fatG: Double = 0.0,
+ val fiberG: Double? = null,
+ val sugarG: Double? = null,
+ val sodiumMg: Double? = null,
+ val sourceRaw: String = "manual",
+ val offProductCode: String? = null,
+ val servingDescription: String? = null,
+ val servingGrams: Double? = null,
+ val quantity: Double = 1.0,
+ val confidenceRaw: String = "medium",
+ val userEdited: Boolean = false,
+ val notes: String? = null,
+ val loggedByCoach: Boolean = false,
+ val createdAt: Long = System.currentTimeMillis(),
+)
+
+@Entity(tableName = "food_products", indices = [Index("lastUsedAt")])
+data class CachedFoodProductEntity(
+ @PrimaryKey val code: String,
+ val name: String,
+ val brand: String? = null,
+ val energyKcal100g: Double,
+ val protein100g: Double = 0.0,
+ val carbs100g: Double = 0.0,
+ val fat100g: Double = 0.0,
+ val fiber100g: Double? = null,
+ val sugars100g: Double? = null,
+ val saturatedFat100g: Double? = null,
+ val sodiumMg100g: Double? = null,
+ val servingSizeText: String? = null,
+ val servingQuantityG: Double? = null,
+ val lastUsedAt: Long = System.currentTimeMillis(),
+ val useCount: Int = 0,
+)
diff --git a/app/src/main/java/com/pulseloop/notifications/CoachNotifications.kt b/app/src/main/java/com/pulseloop/notifications/CoachNotifications.kt
index 1ae6810..bdc1791 100644
--- a/app/src/main/java/com/pulseloop/notifications/CoachNotifications.kt
+++ b/app/src/main/java/com/pulseloop/notifications/CoachNotifications.kt
@@ -159,6 +159,17 @@ class CoachNotificationWorker(
/** iOS `freshnessWindow` (3h) — a live measurement this recent counts as fresh data even
* without a completed full sync (covers rings that stream continuously). */
private const val RECENT_DATA_WINDOW_MS = 3 * 60 * 60_000L
+ // There was a STALE_DATA_WINDOW_MS (1h) here, added for iOS #94. It could never be false:
+ // it was evaluated only *after* the 3h RECENT_DATA_WINDOW_MS early-return above, so
+ // `now - latestMeasurementAt` was already ≥ 3h by the time it ran. Wiring it into the
+ // foreground check therefore deleted that guard outright, letting this worker open a
+ // second transient GATT client while the foreground app held the link — the exact thing
+ // the comment in ensureFreshData says iOS never does.
+ //
+ // iOS #94's real contribution is CoachNotificationDataTrigger: it runs the due slot when a
+ // sync *completes*, so a slot skipped for stale data is delivered a few minutes later
+ // instead of being lost. That is an event-bus subscriber, not a window constant, and it is
+ // not ported yet — see docs/ios-sync.md.
}
override suspend fun doWork(): Result {
diff --git a/app/src/main/java/com/pulseloop/service/DailyCalorieEstimator.kt b/app/src/main/java/com/pulseloop/service/DailyCalorieEstimator.kt
new file mode 100644
index 0000000..c26946a
--- /dev/null
+++ b/app/src/main/java/com/pulseloop/service/DailyCalorieEstimator.kt
@@ -0,0 +1,305 @@
+package com.pulseloop.service
+
+import com.pulseloop.data.PulseLoopDatabase
+import com.pulseloop.data.entity.ActivityBucketEntity
+import com.pulseloop.data.entity.ActivityDailyEntity
+import com.pulseloop.data.entity.ActivitySessionEntity
+import com.pulseloop.data.entity.MeasurementEntity
+import com.pulseloop.ring.MeasurementKind
+
+/**
+ * Ported from `DailyCalorieMath` / `DailyCalorieEstimator` in DailyCalorieEstimator.swift (iOS #98).
+ * Fills in daily calories for rings that report none (LuckRing/TK18, ring-history-only days).
+ *
+ * The model attributes **each interval of the day to exactly one estimator** — workout window >
+ * HR-above-FLEX segment > step bucket — and every term is net of resting energy, so adding the
+ * Mifflin-St Jeor BMR baseline at read time can't double-count. Without that attribution the terms
+ * overlap: a logged run's minutes would be paid for by its session calories, again by its elevated
+ * HR samples, and a third time by its step bucket.
+ */
+object DailyCalorieEstimator {
+
+ private const val FLEX_HR_FRACTION = 0.6
+ private const val MAX_HR = 220.0
+ private const val HR_SAMPLE_MAX_COVERAGE_SECONDS = 600.0
+ private const val BUCKET_DURATION_SECONDS = 900.0
+ private const val INTERMITTENT_CADENCE_SPM = 100.0
+ private const val BRISK_CADENCE_SPM = 100.0
+ private const val RUN_CADENCE_SPM = 130.0
+ private const val DEFAULT_WEIGHT_KG = 70.0
+ private const val DEFAULT_HEIGHT_CM = 170.0
+ private const val DEFAULT_AGE = 35
+ private const val DAY_MS = 86_400_000L
+
+ /**
+ * The `source` value the ring-history ingest writes. iOS drops the ring's own history calorie
+ * field as unverified, so a `ring_history` row's `calories` means "no device value", not zero
+ * burn (`ActivityDaily.deviceReportedCalories`).
+ */
+ const val RING_HISTORY_SOURCE = "ring_history"
+
+ data class Profile(val sex: String?, val age: Int?, val weightKg: Double?, val heightCm: Double?)
+
+ /** Mifflin-St Jeor BMR (kcal/day), clamped at 0 like iOS's `mifflinBMR`. */
+ fun bmr(
+ weightKg: Double = DEFAULT_WEIGHT_KG,
+ heightCm: Double = DEFAULT_HEIGHT_CM,
+ age: Int = DEFAULT_AGE,
+ sex: String? = null,
+ ): Double {
+ val sexConst = when (sex?.lowercase()) {
+ "male" -> 5.0
+ "female" -> -161.0
+ else -> -78.0 // mean of the two constants when sex is unspecified
+ }
+ return maxOf(0.0, 10.0 * weightKg + 6.25 * heightCm - 5.0 * age + sexConst)
+ }
+
+ fun bmrPerMinute(
+ weightKg: Double = DEFAULT_WEIGHT_KG,
+ heightCm: Double = DEFAULT_HEIGHT_CM,
+ age: Int = DEFAULT_AGE,
+ sex: String? = null,
+ ): Double = bmr(weightKg, heightCm, age, sex) / 1440.0
+
+ private fun bmrPerMinute(p: Profile): Double = bmrPerMinute(
+ p.weightKg ?: DEFAULT_WEIGHT_KG,
+ p.heightCm ?: DEFAULT_HEIGHT_CM,
+ p.age ?: DEFAULT_AGE,
+ p.sex,
+ )
+
+ /** Flex-HR threshold: only samples at or above this contribute (Spurr's Flex-HR method). */
+ fun flexHR(age: Int = DEFAULT_AGE): Double = FLEX_HR_FRACTION * (MAX_HR - age)
+
+ // ── Read-time selection ─────────────────────────────────────────────────────
+
+ /**
+ * The calories the *device itself* reported, or null when it gave none.
+ *
+ * Mirrors iOS `ActivityDaily.deviceReportedCalories`:
+ * `source == ringHistorySource || calories <= 0 ? nil : calories`. Note the direction — a
+ * `ring_history` row never counts as a device value however large its `calories` column is.
+ * This was inverted on first port (it returned the value *only* for `ring_history`, and ignored
+ * genuine device calories from every other source), which made the estimate replace real data
+ * and real data replace the estimate.
+ */
+ fun deviceReportedCalories(day: ActivityDailyEntity): Double? =
+ if (day.source == RING_HISTORY_SOURCE || day.calories <= 0.0) null else day.calories
+
+ /**
+ * What to display: the device's own figure when it reported one, else the estimated **total** —
+ * BMR accrued over the day's elapsed minutes (so today's number grows from midnight, the way
+ * Fitbit/Oura/Whoop present it) plus the stored net active estimate.
+ */
+ fun effectiveCalories(
+ day: ActivityDailyEntity,
+ profile: Profile,
+ now: Long = System.currentTimeMillis(),
+ ): Double? {
+ deviceReportedCalories(day)?.let { return it }
+ val active = day.estimatedActiveCalories ?: return null
+ return bmrPerMinute(profile) * elapsedMinutes(day.date, now) + active
+ }
+
+ /**
+ * The active-energy portion — what the calorie *goal ring* measures, so `UserGoal.calories`
+ * stays an active-energy goal even when the displayed total includes basal burn.
+ */
+ fun effectiveActiveCalories(day: ActivityDailyEntity): Double? =
+ deviceReportedCalories(day) ?: day.estimatedActiveCalories
+
+ /** Minutes of [dayStart] that have elapsed: partial for today, full for the past, 0 ahead. */
+ private fun elapsedMinutes(dayStart: Long, now: Long): Double = when {
+ now < dayStart -> 0.0
+ now >= dayStart + DAY_MS -> 1440.0
+ else -> ((now - dayStart) / 60_000.0).coerceIn(0.0, 1440.0)
+ }
+
+ // ── Recompute ───────────────────────────────────────────────────────────────
+
+ /**
+ * Recompute one day from scratch — idempotent, so re-syncs and repeated calls converge.
+ *
+ * **No `activity_daily` row → no-op.** A day with nothing synced and nothing logged has nothing
+ * to estimate against, and inserting a row here would fabricate a zero-step `ring_history` day
+ * in the history and charts (iOS: `guard let row = MetricsRepository.activity(...) else return`).
+ */
+ suspend fun recompute(dayStart: Long, db: PulseLoopDatabase, profile: Profile) {
+ val row = db.activityDailyDao().byDay(dayStart) ?: return
+ val dayEnd = dayStart + DAY_MS
+
+ val hrSamples = db.measurementDao().range(MeasurementKind.HEART_RATE.name, dayStart, dayEnd)
+ val buckets = db.activityBucketDao().byDay(dayStart)
+ val workouts = db.activitySessionDao().recent(WORKOUT_SCAN_LIMIT).filter {
+ it.statusRaw == "finished" && it.endedAt != null &&
+ it.startedAt < dayEnd && it.endedAt!! > dayStart
+ }
+
+ val active = estimateNetActive(
+ dayStart = dayStart,
+ dayTotalSteps = row.steps,
+ buckets = buckets,
+ workouts = workouts,
+ hrSamples = hrSamples,
+ profile = profile,
+ )
+ db.activityDailyDao().upsert(row.copy(
+ estimatedActiveCalories = active,
+ updatedAt = System.currentTimeMillis(),
+ ))
+ }
+
+ /** A half-open interval, in epoch millis, already attributed to some estimator. */
+ private data class Window(val start: Long, val end: Long)
+
+ /**
+ * Net active calories for one day — excludes the BMR baseline, which [effectiveCalories] adds
+ * at read time. Pure, so it can be unit-tested without Room.
+ */
+ fun estimateNetActive(
+ dayStart: Long,
+ dayTotalSteps: Int,
+ buckets: List,
+ workouts: List,
+ hrSamples: List,
+ profile: Profile,
+ ): Double {
+ val dayEnd = dayStart + DAY_MS
+ val weight = profile.weightKg ?: DEFAULT_WEIGHT_KG
+ val bmrPerMin = bmrPerMinute(profile)
+ val covered = mutableListOf()
+ var activeKcal = 0.0
+
+ // 1. Workouts — reuse the session's stored calories, prorated across midnight and netted of
+ // the resting energy the BMR baseline already covers for those minutes.
+ for (workout in workouts) {
+ val end = workout.endedAt ?: continue
+ val clippedStart = maxOf(workout.startedAt, dayStart)
+ val clippedEnd = minOf(end, dayEnd)
+ if (clippedEnd <= clippedStart) continue
+ val totalMs = (end - workout.startedAt).toDouble()
+ val clippedMs = (clippedEnd - clippedStart).toDouble()
+ val fraction = if (totalMs > 0) clippedMs / totalMs else 0.0
+ activeKcal += maxOf(0.0, (workout.calories ?: 0.0) * fraction - bmrPerMin * clippedMs / 60_000.0)
+ covered.add(Window(clippedStart, clippedEnd))
+ }
+
+ // 2. All-day HR above the FLEX threshold, outside workout windows — the Keytel per-minute
+ // rate net of resting, over each sample's coverage interval. Catches unlogged exertion.
+ //
+ // Gated on the same profile completeness the workout Keytel path needs: Keytel's
+ // regression has separate male/female forms, so an unspecified sex would silently be
+ // scored as female. iOS applies the identical guard; without it the HR term ran for
+ // every user and quietly used the wrong equation.
+ val sex = profile.sex?.lowercase()
+ val age = profile.age
+ val profileWeight = profile.weightKg
+ if ((sex == "male" || sex == "female") && age != null && profileWeight != null) {
+ val flex = flexHR(age)
+ val samples = hrSamples
+ .filter { it.value > 0 && it.timestamp >= dayStart && it.timestamp < dayEnd }
+ .sortedBy { it.timestamp }
+ for ((index, sample) in samples.withIndex()) {
+ if (sample.value < flex) continue
+ val nextTs = if (index + 1 < samples.size) samples[index + 1].timestamp else dayEnd
+ val coverageSec = ((nextTs - sample.timestamp) / 1000.0)
+ .coerceIn(0.0, HR_SAMPLE_MAX_COVERAGE_SECONDS)
+ val segment = Window(sample.timestamp, sample.timestamp + (coverageSec * 1000).toLong())
+ val minutes = maxOf(0.0, coverageSec - overlapSeconds(segment, covered)) / 60.0
+ if (minutes <= 0.0) continue
+ val rate = WorkoutMetricsEngine.keytelCalorieRate(sample.value, sex, age, profileWeight)
+ activeKcal += maxOf(0.0, rate - bmrPerMin) * minutes
+ covered.add(segment)
+ }
+ }
+
+ // 3. Step buckets — cadence-tiered walking/running METs net of 1 MET, scaled by the
+ // fraction of the bucket not already attributed above.
+ var stepKcal = 0.0
+ var bucketSteps = 0
+ for (bucket in buckets) {
+ bucketSteps += bucket.steps
+ if (bucket.steps <= 0) continue
+ val bucketEnd = bucket.startEpoch + (BUCKET_DURATION_SECONDS * 1000).toLong()
+ val keepFraction = 1.0 -
+ overlapSeconds(Window(bucket.startEpoch, bucketEnd), covered) / BUCKET_DURATION_SECONDS
+ if (keepFraction <= 0.0) continue
+ val durationMinutes = BUCKET_DURATION_SECONDS / 60.0
+ val cadence = bucket.steps / durationMinutes
+ val met: Double
+ val activeMinutes: Double
+ when {
+ cadence >= RUN_CADENCE_SPM -> { met = 8.3; activeMinutes = durationMinutes }
+ cadence >= BRISK_CADENCE_SPM -> { met = 3.5; activeMinutes = durationMinutes }
+ else -> {
+ met = intermittentWalkMET(profile.heightCm)
+ activeMinutes = bucket.steps / INTERMITTENT_CADENCE_SPM
+ }
+ }
+ stepKcal += maxOf(0.0, met - 1) * weight * (activeMinutes * keepFraction) / 60.0
+ }
+
+ // 4. Residual steps the buckets don't represent — live-only days, or today's cumulative
+ // counter running ahead of the bucket log. With no buckets at all, deduct an allowance
+ // for steps taken inside already-covered windows (a run's steps are in the day total but
+ // its energy is already counted).
+ var residual = maxOf(0, dayTotalSteps - bucketSteps).toDouble()
+ if (buckets.isEmpty()) {
+ val coveredMinutes = mergedDurationSeconds(covered) / 60.0
+ residual = maxOf(0.0, residual - coveredMinutes * INTERMITTENT_CADENCE_SPM)
+ }
+ val residualMET = intermittentWalkMET(profile.heightCm)
+ stepKcal += maxOf(0.0, residualMET - 1) * weight * (residual / INTERMITTENT_CADENCE_SPM) / 60.0
+
+ return maxOf(0.0, activeKcal + stepKcal)
+ }
+
+ /**
+ * Walking MET for intermittent stepping at ~100 steps/min, refined by stride length from height
+ * when it's known (stride ≈ 0.414 × height → speed → Compendium walking tier). Height-unknown
+ * returns 3.0, iOS's own fallback — deliberately *not* the tier that the 170 cm default would
+ * produce, since that would silently assert a stride we don't have.
+ */
+ fun intermittentWalkMET(heightCm: Double?): Double {
+ if (heightCm == null || heightCm <= 0) return 3.0
+ val strideM = 0.414 * heightCm / 100.0
+ val speedMps = strideM * INTERMITTENT_CADENCE_SPM / 60.0
+ return when {
+ speedMps < 1.0 -> 2.8 // < 3.6 km/h easy
+ speedMps < 1.35 -> 3.5 // ~4.8 km/h moderate
+ speedMps < 1.65 -> 4.3 // ~5.6 km/h brisk
+ else -> 5.0 // ≥ 6 km/h very brisk
+ }
+ }
+
+ // ── Interval helpers ────────────────────────────────────────────────────────
+
+ private fun overlapSeconds(interval: Window, windows: List): Double =
+ mergedDurationSeconds(
+ windows.mapNotNull { w ->
+ val start = maxOf(interval.start, w.start)
+ val end = minOf(interval.end, w.end)
+ if (end > start) Window(start, end) else null
+ }
+ )
+
+ private fun mergedDurationSeconds(windows: List): Double {
+ var total = 0L
+ var currentEnd = Long.MIN_VALUE
+ for (w in windows.sortedBy { it.start }) {
+ val start = maxOf(w.start, currentEnd)
+ if (w.end > start) {
+ total += w.end - start
+ currentEnd = w.end
+ }
+ }
+ return total / 1000.0
+ }
+
+ /**
+ * How many recent sessions to scan for windows overlapping the day. iOS fetches all sessions;
+ * Android pages, and a day can only overlap sessions from that day and its neighbours.
+ */
+ private const val WORKOUT_SCAN_LIMIT = 50
+}
diff --git a/app/src/main/java/com/pulseloop/service/EventPersistenceSubscriber.kt b/app/src/main/java/com/pulseloop/service/EventPersistenceSubscriber.kt
index ab97863..649dc72 100644
--- a/app/src/main/java/com/pulseloop/service/EventPersistenceSubscriber.kt
+++ b/app/src/main/java/com/pulseloop/service/EventPersistenceSubscriber.kt
@@ -275,15 +275,17 @@ class EventPersistenceSubscriber(
upsertSleepSession(event.timestamp.toEpochMilli(), event.stages, event.completeSession)
}
is PulseEvent.SyncProgress -> {
- // Only "done" (a history sync actually completed) stamps lastFullSyncAt — the
- // coach-notification freshness gate (iOS #61c). Bare CONNECT already re-stamps
- // the looser lastSyncAt elsewhere and must not touch this one.
if (event.stage == "done") {
val device = db.deviceDao().currentReal()
if (device != null) {
db.deviceDao().upsert(device.copy(lastFullSyncAt = System.currentTimeMillis()))
}
reconcileRecentlyFinishedWorkouts()
+ recomputeCalorieEstimates()
+ // iOS #95: re-learn the resting-HR baseline that drives the "auto" HR zone
+ // mode. Self-throttled to every 6h, so calling it on every completed sync is
+ // cheap.
+ RestingHRBaselineService.refreshIfStale(db)
}
}
is PulseEvent.HeartRateComplete -> {}
@@ -625,6 +627,22 @@ class EventPersistenceSubscriber(
}
}
+ // ── Calorie estimation recompute (iOS #98) ───────────────────────────────────
+
+ private suspend fun recomputeCalorieEstimates() {
+ val profile = db.userProfileDao().get() ?: return
+ val estimator = DailyCalorieEstimator.Profile(
+ sex = profile.sex, age = profile.age,
+ weightKg = profile.weightKg, heightCm = profile.heightCm,
+ )
+ val now = System.currentTimeMillis()
+ val dayMs = 86_400_000L
+ for (d in 0 until 7) {
+ val dayStart = com.pulseloop.util.TimeUtil.startOfDayLocal(now - d * dayMs)
+ DailyCalorieEstimator.recompute(dayStart, db, estimator)
+ }
+ }
+
private companion object {
const val MAX_SLEEP_TIMELINE_MINUTES = 24 * 60
}
@@ -699,6 +717,7 @@ internal fun replaceOverlappingSleepBlocks(
)
}
}
+
for (block in replacements) byStart[block.startAt] = block
return byStart.values.sortedBy { it.startAt }
}
diff --git a/app/src/main/java/com/pulseloop/service/LiveWorkoutManager.kt b/app/src/main/java/com/pulseloop/service/LiveWorkoutManager.kt
index d95c932..ea973ed 100644
--- a/app/src/main/java/com/pulseloop/service/LiveWorkoutManager.kt
+++ b/app/src/main/java/com/pulseloop/service/LiveWorkoutManager.kt
@@ -148,6 +148,25 @@ class LiveWorkoutManager(
coordinator.stopWorkoutHeartRate()
finishForegroundService(summarized)
_state.value = WorkoutState(finishedSessionId = summarized.id)
+ uploadToStravaIfConnected()
+ }
+
+ /**
+ * iOS #100 auto-upload. This is what makes the Strava settings screen's "finished workouts are
+ * automatically uploaded" true — before it existed, `uploadAuto` had exactly one caller, the
+ * manual "Sync Now" button.
+ *
+ * Best-effort and non-blocking: a Strava outage must never fail or delay finishing a workout.
+ * `uploadAuto` no-ops when disconnected and skips anything that predates the connection.
+ */
+ private suspend fun uploadToStravaIfConnected() {
+ if (!com.pulseloop.strava.StravaAuth.isConfigured) return
+ runCatching {
+ val store = com.pulseloop.strava.StravaTokenStore(context)
+ if (store.isConnected) com.pulseloop.strava.StravaUploader.uploadAuto(db, store)
+ }.onFailure {
+ android.util.Log.w("LiveWorkoutManager", "Strava auto-upload failed: ${it.message}")
+ }
}
suspend fun cancel(session: ActivitySessionEntity) {
diff --git a/app/src/main/java/com/pulseloop/service/RestingHRBaselineService.kt b/app/src/main/java/com/pulseloop/service/RestingHRBaselineService.kt
new file mode 100644
index 0000000..dc7562c
--- /dev/null
+++ b/app/src/main/java/com/pulseloop/service/RestingHRBaselineService.kt
@@ -0,0 +1,71 @@
+package com.pulseloop.service
+
+import com.pulseloop.data.PulseLoopDatabase
+import com.pulseloop.data.entity.UserProfileEntity
+import com.pulseloop.ring.MeasurementKind
+import com.pulseloop.util.TimeUtil
+import kotlin.math.ceil
+import kotlin.math.roundToInt
+
+/**
+ * Ported from RestingHRBaselineService.swift (iOS #95).
+ * Learns a personalized resting-HR baseline from the 10th percentile of the trailing 30 days
+ * of HR samples. Needs ≥20 samples spanning ≥7 days to establish.
+ *
+ * Called from [EventPersistenceSubscriber] on sync completion. That wiring is load-bearing: with no
+ * caller, `hrRestingBaseline` stays null forever and [VitalsThresholdEngine]'s default `"auto"` HR
+ * zone mode silently falls back to fixed 50/90 boundaries — which is how it shipped on first port,
+ * so the feature moved everyone's "normal" band without giving anyone the personalisation that
+ * justified moving it.
+ */
+object RestingHRBaselineService {
+
+ private const val BASELINE_DAYS = 30
+ private const val MIN_SAMPLES = 20
+ private const val MIN_CALENDAR_DAYS = 7
+ private const val REFRESH_INTERVAL_MS = 6 * 3600_000L
+
+ suspend fun refreshIfStale(db: PulseLoopDatabase) {
+ val profile = db.userProfileDao().get() ?: return
+ val lastUpdate = profile.hrRestingBaselineUpdatedAt ?: 0L
+ val now = System.currentTimeMillis()
+ if (now - lastUpdate < REFRESH_INTERVAL_MS) return
+ refresh(db, profile)
+ }
+
+ private suspend fun refresh(db: PulseLoopDatabase, profile: UserProfileEntity) {
+ val now = System.currentTimeMillis()
+ val start = now - BASELINE_DAYS * 86_400_000L
+ // Room suspend queries already run off the main thread on their own dispatcher.
+ val samples = db.measurementDao().range(MeasurementKind.HEART_RATE.name, start, now)
+ if (samples.size < MIN_SAMPLES) return
+
+ val values = samples.map { it.value }.sorted()
+ // Distinct *local* days: `timestamp / 86_400_000` buckets by UTC, which silently splits or
+ // merges a day for anyone far enough from GMT and skews the ≥7-day spread requirement.
+ val calendarDays = samples.map { TimeUtil.startOfDayLocal(it.timestamp) }.distinct().size
+ if (calendarDays < MIN_CALENDAR_DAYS) return
+
+ val baselineP10 = percentile(values, 0.10)
+ val rounded = (baselineP10 * 2).roundToInt() / 2.0 // round to nearest 0.5
+
+ if (profile.hrRestingBaseline != rounded) {
+ db.userProfileDao().upsert(profile.copy(
+ hrRestingBaseline = rounded,
+ hrRestingBaselineUpdatedAt = now,
+ updatedAt = now,
+ ))
+ }
+ }
+
+ private fun percentile(sorted: List, fraction: Double): Double {
+ if (sorted.isEmpty()) return 0.0
+ if (sorted.size == 1) return sorted.first()
+ val rank = fraction * (sorted.size - 1)
+ val lower = rank.toInt()
+ val upper = kotlin.math.ceil(rank).toInt().coerceAtMost(sorted.size - 1)
+ if (lower == upper) return sorted[lower]
+ val weight = rank - lower
+ return sorted[lower] * (1 - weight) + sorted[upper] * weight
+ }
+}
diff --git a/app/src/main/java/com/pulseloop/service/VitalsThresholdEngine.kt b/app/src/main/java/com/pulseloop/service/VitalsThresholdEngine.kt
index 059a63b..b9a1f98 100644
--- a/app/src/main/java/com/pulseloop/service/VitalsThresholdEngine.kt
+++ b/app/src/main/java/com/pulseloop/service/VitalsThresholdEngine.kt
@@ -140,17 +140,51 @@ object VitalsThresholdEngine {
// ─────────────────────────── Heart rate ───────────────────────────
private fun heartRateZones(profile: UserPhysiologyProfile): List {
- // Athletes commonly rest below 60 (and even near 40) — that is optimal, not a concern.
- // Beta-blockers also lower resting HR; we relabel rather than alarm.
+ val mode = profile.hrZoneModeRaw ?: "auto"
+ val athlete = profile.athleteMode
+ val baseline = profile.hrRestingBaseline
+
+ val lowUpper: Double
+ val athleticUpper: Double?
+ val elevatedStart: Double
+ val highStart: Double
+
+ when (mode) {
+ "custom" -> {
+ lowUpper = profile.hrCustomLowUpper ?: 50.0
+ athleticUpper = if (athlete) profile.hrCustomAthleticUpper ?: 60.0 else null
+ elevatedStart = profile.hrCustomElevatedStart ?: 90.0
+ highStart = profile.hrCustomHighStart ?: 120.0
+ }
+ "auto" -> {
+ if (baseline != null) {
+ lowUpper = clamp(baseline - 12, min = 35.0, max = if (athlete) 40.0 else 55.0)
+ elevatedStart = clamp(baseline + 40, min = 85.0, max = 105.0)
+ highStart = elevatedStart + 25
+ } else {
+ lowUpper = if (athlete) 40.0 else 50.0
+ elevatedStart = 90.0
+ highStart = 120.0
+ }
+ athleticUpper = if (athlete) 60.0 else null
+ }
+ else -> { // "standard"
+ lowUpper = if (athlete) 40.0 else 50.0
+ athleticUpper = if (athlete) 60.0 else null
+ elevatedStart = 90.0
+ highStart = 120.0
+ }
+ }
+
val lowLabel: String
val lowSeverity: ZoneSeverity
val lowExplanation: String
val lowColor: VitalColorToken
when {
- profile.athleteMode -> {
+ athlete -> {
lowLabel = "Athletic"
lowSeverity = ZoneSeverity.OPTIMAL
- lowColor = VitalColorToken.MetricAccent(MetricKind.HEART_RATE) // a low athletic HR is good, not a caution
+ lowColor = VitalColorToken.MetricAccent(MetricKind.HEART_RATE)
lowExplanation = "A low resting heart rate is common with high fitness."
}
profile.usesBetaBlockers -> {
@@ -166,27 +200,28 @@ object VitalsThresholdEngine {
lowExplanation = "Below the typical resting range. Often fine, but worth noting if you feel faint."
}
}
- return listOf(
- MetricZone("hr.low", lowLabel, null, 60.0, lowSeverity, lowColor, lowExplanation),
- // 60–100 inclusive is normal, so the half-open upper bound is 101.
- MetricZone(
- "hr.normal", "Normal", 60.0, 101.0,
- ZoneSeverity.NORMAL, VitalColorToken.MetricAccent(MetricKind.HEART_RATE),
- "A typical resting heart rate for adults is 60–100 bpm.",
- ),
- MetricZone(
- "hr.elevated", "Elevated", 101.0, 120.0,
- ZoneSeverity.WATCH, VitalColorToken.Amber,
- "Above the typical resting range. Activity, caffeine, or stress can raise it.",
- ),
- MetricZone(
- "hr.high", "High", 120.0, null,
- ZoneSeverity.HIGH, VitalColorToken.BrightRed,
- "A high resting heart rate. Talk to a clinician if it persists at rest.",
- ),
- )
+
+ val zones = mutableListOf()
+ zones.add(MetricZone("hr.low", lowLabel, null, lowUpper, lowSeverity, lowColor, lowExplanation))
+ if (athlete && athleticUpper != null) {
+ zones.add(MetricZone("hr.athletic", "Athletic", lowUpper, athleticUpper, ZoneSeverity.OPTIMAL, VitalColorToken.Mint, "Athletic resting range."))
+ }
+ val normalLow = if (athlete && athleticUpper != null) athleticUpper else lowUpper
+ zones.add(MetricZone("hr.normal", "Normal", normalLow, elevatedStart,
+ ZoneSeverity.NORMAL, VitalColorToken.MetricAccent(MetricKind.HEART_RATE),
+ "A typical resting heart rate for adults."))
+ zones.add(MetricZone("hr.elevated", "Elevated", elevatedStart, highStart,
+ ZoneSeverity.WATCH, VitalColorToken.Amber,
+ "Above the typical resting range. Activity, caffeine, or stress can raise it."))
+ zones.add(MetricZone("hr.high", "High", highStart, null,
+ ZoneSeverity.HIGH, VitalColorToken.BrightRed,
+ "A high resting heart rate. Talk to a clinician if it persists at rest."))
+ return zones
}
+ private fun clamp(value: Double, min: Double, max: Double): Double =
+ value.coerceIn(min, max)
+
// ─────────────────────────── SpO₂ ───────────────────────────
private fun spo2Zones(profile: UserPhysiologyProfile): List {
diff --git a/app/src/main/java/com/pulseloop/service/VitalsZoneModel.kt b/app/src/main/java/com/pulseloop/service/VitalsZoneModel.kt
index e24c666..e4eceda 100644
--- a/app/src/main/java/com/pulseloop/service/VitalsZoneModel.kt
+++ b/app/src/main/java/com/pulseloop/service/VitalsZoneModel.kt
@@ -200,6 +200,13 @@ data class UserPhysiologyProfile(
val usesBetaBlockers: Boolean = false,
val hasKnownLungCondition: Boolean = false,
val preferredGlucoseUnit: GlucoseUnit = GlucoseUnit.MGDL,
+ /** iOS #95: HR zone mode. */
+ val hrZoneModeRaw: String? = "auto",
+ val hrRestingBaseline: Double? = null,
+ val hrCustomLowUpper: Double? = null,
+ val hrCustomAthleticUpper: Double? = null,
+ val hrCustomElevatedStart: Double? = null,
+ val hrCustomHighStart: Double? = null,
) {
/** Age-predicted maximum heart rate (`220 − age`), used for effort-zone overlays. Falls back to
* 190 when age is unknown. */
@@ -225,6 +232,12 @@ data class UserPhysiologyProfile(
usesBetaBlockers: Boolean = false,
hasKnownLungCondition: Boolean = false,
preferredGlucoseUnit: GlucoseUnit = GlucoseUnit.MGDL,
+ hrZoneModeRaw: String? = "auto",
+ hrRestingBaseline: Double? = null,
+ hrCustomLowUpper: Double? = null,
+ hrCustomAthleticUpper: Double? = null,
+ hrCustomElevatedStart: Double? = null,
+ hrCustomHighStart: Double? = null,
): UserPhysiologyProfile =
UserPhysiologyProfile(
age = age,
@@ -234,6 +247,12 @@ data class UserPhysiologyProfile(
usesBetaBlockers = usesBetaBlockers,
hasKnownLungCondition = hasKnownLungCondition,
preferredGlucoseUnit = preferredGlucoseUnit,
+ hrZoneModeRaw = hrZoneModeRaw,
+ hrRestingBaseline = hrRestingBaseline,
+ hrCustomLowUpper = hrCustomLowUpper,
+ hrCustomAthleticUpper = hrCustomAthleticUpper,
+ hrCustomElevatedStart = hrCustomElevatedStart,
+ hrCustomHighStart = hrCustomHighStart,
)
}
}
diff --git a/app/src/main/java/com/pulseloop/service/WorkoutMetricsEngine.kt b/app/src/main/java/com/pulseloop/service/WorkoutMetricsEngine.kt
index 954975b..7e73062 100644
--- a/app/src/main/java/com/pulseloop/service/WorkoutMetricsEngine.kt
+++ b/app/src/main/java/com/pulseloop/service/WorkoutMetricsEngine.kt
@@ -76,14 +76,16 @@ object WorkoutMetricsEngine {
if (byMinute.isEmpty() || byMinute.size / minutes < KEYTEL_COVERAGE_THRESHOLD) return null
val rates = byMinute.values.map { bucket ->
val meanHR = bucket.sumOf { it.second } / bucket.size
- keytelRate(meanHR, male = sex == "male", age = age.toDouble(), weightKg = weight)
+ keytelCalorieRate(meanHR, sex, age, weight)
}
val meanRate = rates.sum() / rates.size
return maxOf(0.0, meanRate * minutes)
}
- /** kcal/min for a given heart rate (Keytel et al. 2005, without VO2max), clamped >= 0. */
- private fun keytelRate(hr: Double, male: Boolean, age: Double, weightKg: Double): Double {
+ /** kcal/min for a given heart rate (Keytel et al. 2005, without VO2max), clamped >= 0.
+ * Made public for DailyCalorieEstimator (iOS #98). */
+ fun keytelCalorieRate(hr: Double, sex: String?, age: Int, weightKg: Double): Double {
+ val male = sex?.lowercase() == "male"
val kj = if (male)
-55.0969 + 0.6309 * hr + 0.1988 * weightKg + 0.2017 * age
else
diff --git a/app/src/main/java/com/pulseloop/strava/StravaAuth.kt b/app/src/main/java/com/pulseloop/strava/StravaAuth.kt
new file mode 100644
index 0000000..991b284
--- /dev/null
+++ b/app/src/main/java/com/pulseloop/strava/StravaAuth.kt
@@ -0,0 +1,212 @@
+package com.pulseloop.strava
+
+import android.net.Uri
+import com.pulseloop.BuildConfig
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.json.Json
+import okhttp3.OkHttpClient
+import java.util.UUID
+import java.util.concurrent.TimeUnit
+
+@Serializable
+data class StravaTokens(
+ val accessToken: String,
+ val refreshToken: String,
+ val expiresAt: Long, // epoch seconds
+ val athleteId: Long? = null,
+ val athleteName: String? = null,
+)
+
+object StravaAuth {
+
+ private const val AUTH_BASE = "https://www.strava.com/oauth"
+ private const val REDIRECT_URI = "pulseloop://localhost/strava-auth"
+ private const val SCOPES = "activity:write,read"
+
+ private val json = Json { ignoreUnknownKeys = true }
+
+ private val httpClient = OkHttpClient.Builder()
+ .connectTimeout(30, TimeUnit.SECONDS)
+ .readTimeout(30, TimeUnit.SECONDS)
+ .build()
+
+ private val refreshMutex = Mutex()
+
+ val isConfigured: Boolean
+ get() = BuildConfig.STRAVA_CLIENT_ID.isNotBlank() && BuildConfig.STRAVA_CLIENT_SECRET.isNotBlank()
+
+ /**
+ * Build the authorize URL and record its CSRF state.
+ *
+ * The state is persisted, not held in a field: the flow leaves for a browser or the Strava app,
+ * and Android is free to kill this process while it's gone — which is exactly why
+ * `MainActivity` has a cold-start redirect handler at all. An in-memory state would be null on
+ * that path, so every process-death authorization would fail validation and be dropped in
+ * silence.
+ *
+ * `GET /oauth/mobile/authorize` (not `/oauth/authorize`) is the Android endpoint per Strava's
+ * own docs — dispatched with an implicit ACTION_VIEW intent so the Strava app handles it when
+ * installed and the browser otherwise.
+ */
+ fun generateAuthUrl(store: StravaTokenStore): String {
+ val state = UUID.randomUUID().toString()
+ store.savePendingAuthState(state)
+ return "$AUTH_BASE/mobile/authorize?" +
+ "client_id=${BuildConfig.STRAVA_CLIENT_ID}&redirect_uri=${Uri.encode(REDIRECT_URI)}" +
+ "&response_type=code&scope=${Uri.encode(SCOPES)}&approval_prompt=auto&state=${Uri.encode(state)}"
+ }
+
+ /** One-shot: a state can only be redeemed once, and is cleared whether or not it matched. */
+ fun validateState(store: StravaTokenStore, state: String?): Boolean {
+ val expected = store.takePendingAuthState()
+ return expected != null && state != null && expected == state
+ }
+
+ /**
+ * Strava echoes the granted scopes on the callback, and the user can untick "Upload your
+ * activities" on the consent screen. Without `activity:write` every upload would fail later
+ * with an opaque 401, so the connect is rejected up front instead.
+ */
+ fun grantedScopeIncludesWrite(scope: String?): Boolean =
+ scope?.split(",")?.map { it.trim() }?.contains("activity:write") == true
+
+ suspend fun exchangeCode(code: String): StravaTokens {
+ val body = okhttp3.FormBody.Builder()
+ .add("client_id", BuildConfig.STRAVA_CLIENT_ID)
+ .add("client_secret", BuildConfig.STRAVA_CLIENT_SECRET)
+ .add("code", code)
+ .add("grant_type", "authorization_code")
+ .build()
+ val request = okhttp3.Request.Builder()
+ .url("$AUTH_BASE/token")
+ .post(body)
+ .build()
+ val response = httpClient.newCall(request).execute()
+ val raw = response.body?.string() ?: throw java.io.IOException("Empty token response")
+ if (!response.isSuccessful) throw java.io.IOException("Token exchange failed: $raw")
+ return parseTokenResponse(raw)
+ }
+
+ /**
+ * Single-flight refresh. Strava **rotates** the refresh token on every exchange, so two
+ * concurrent callers must not each run a refresh: the second would present a token the first
+ * already burned and get rejected, and its failure would look like a revoked authorization.
+ *
+ * Holding a mutex is not enough on its own — the previous version serialized the two calls but
+ * each still used the refresh token it had captured *before* the lock. The fix is to re-read
+ * the store inside the lock: whoever gets there second sees the rotated token already saved by
+ * the first and returns it instead of spending it again.
+ */
+ suspend fun refreshToken(tokens: StravaTokens, tokenStore: StravaTokenStore): StravaTokens =
+ refreshMutex.withLock {
+ val current = tokenStore.get() ?: tokens
+ val alreadyRotated = current.refreshToken != tokens.refreshToken ||
+ current.expiresAt - EXPIRY_LEEWAY_SECONDS > System.currentTimeMillis() / 1000
+ if (alreadyRotated) return@withLock current
+ val rotated = refreshTokenInternal(current)
+ tokenStore.save(rotated)
+ rotated
+ }
+
+ private suspend fun refreshTokenInternal(tokens: StravaTokens): StravaTokens {
+ val body = okhttp3.FormBody.Builder()
+ .add("client_id", BuildConfig.STRAVA_CLIENT_ID)
+ .add("client_secret", BuildConfig.STRAVA_CLIENT_SECRET)
+ .add("grant_type", "refresh_token")
+ .add("refresh_token", tokens.refreshToken)
+ .build()
+ val request = okhttp3.Request.Builder()
+ .url("$AUTH_BASE/token")
+ .post(body)
+ .build()
+ val response = httpClient.newCall(request).execute()
+ val raw = response.body?.string() ?: throw java.io.IOException("Empty refresh response")
+ if (!response.isSuccessful) throw java.io.IOException("Token refresh failed: $raw")
+ return parseTokenResponse(raw)
+ }
+
+ suspend fun authenticatedRequest(
+ tokens: StravaTokens,
+ tokenStore: StravaTokenStore,
+ method: String,
+ url: String,
+ body: okhttp3.RequestBody? = null,
+ ): okhttp3.Response {
+ var current = tokens
+ if (current.expiresAt - EXPIRY_LEEWAY_SECONDS < System.currentTimeMillis() / 1000) {
+ current = refreshToken(current, tokenStore)
+ }
+ val builder = okhttp3.Request.Builder()
+ .url(url)
+ .header("Authorization", "Bearer ${current.accessToken}")
+ when (method) {
+ "GET" -> builder.get()
+ "POST" -> builder.post(body ?: okhttp3.RequestBody.create(null, ByteArray(0)))
+ "PUT" -> builder.put(body ?: okhttp3.RequestBody.create(null, ByteArray(0)))
+ }
+ val response = httpClient.newCall(builder.build()).execute()
+ if (response.code == 401) {
+ response.close()
+ val refreshed = refreshToken(current, tokenStore)
+ val retry = httpClient.newCall(
+ okhttp3.Request.Builder()
+ .url(url)
+ .header("Authorization", "Bearer ${refreshed.accessToken}")
+ .apply {
+ when (method) {
+ "GET" -> get()
+ "POST" -> post(body ?: okhttp3.RequestBody.create(null, ByteArray(0)))
+ "PUT" -> put(body ?: okhttp3.RequestBody.create(null, ByteArray(0)))
+ }
+ }
+ .build()
+ ).execute()
+ return retry
+ }
+ return response
+ }
+
+ /**
+ * Best-effort revoke, so disconnecting here also drops PulseLoop from the athlete's
+ * "My Apps" list on strava.com. Without it, clearing the local tokens leaves the
+ * authorization live forever — iOS calls this on every disconnect.
+ *
+ * Uses the legacy `POST /oauth/deauthorize`; Strava's docs now recommend `/oauth/revoke` with
+ * HTTP Basic client credentials (as of 2026-06-01) but keep deauthorize working.
+ */
+ suspend fun deauthorize(tokens: StravaTokens) {
+ val request = okhttp3.Request.Builder()
+ .url("$AUTH_BASE/deauthorize")
+ .header("Authorization", "Bearer ${tokens.accessToken}")
+ .post(okhttp3.FormBody.Builder().build())
+ .build()
+ runCatching { httpClient.newCall(request).execute().close() }
+ }
+
+ /** Refresh when fewer than this many seconds of validity remain (iOS `expiryLeeway`). */
+ private const val EXPIRY_LEEWAY_SECONDS = 300L
+
+ @Serializable
+ private data class TokenResponse(
+ val access_token: String,
+ val refresh_token: String,
+ val expires_at: Long,
+ val athlete: Athlete? = null,
+ )
+
+ @Serializable
+ private data class Athlete(val id: Long, val firstname: String? = null, val lastname: String? = null)
+
+ private fun parseTokenResponse(raw: String): StravaTokens {
+ val tr = json.decodeFromString(raw)
+ return StravaTokens(
+ accessToken = tr.access_token,
+ refreshToken = tr.refresh_token,
+ expiresAt = tr.expires_at,
+ athleteId = tr.athlete?.id,
+ athleteName = listOfNotNull(tr.athlete?.firstname, tr.athlete?.lastname).joinToString(" ").ifBlank { null },
+ )
+ }
+}
diff --git a/app/src/main/java/com/pulseloop/strava/StravaSportMapping.kt b/app/src/main/java/com/pulseloop/strava/StravaSportMapping.kt
new file mode 100644
index 0000000..0f7af5e
--- /dev/null
+++ b/app/src/main/java/com/pulseloop/strava/StravaSportMapping.kt
@@ -0,0 +1,42 @@
+package com.pulseloop.strava
+
+/**
+ * Maps PulseLoop activity types onto Strava's `sport_type` enum. Ported from
+ * StravaSportMapping.swift (iOS #100).
+ *
+ * Distinct from the TCX `` names in [StravaTCXBuilder] — by design: TCX v2 only
+ * allows Running / Biking / Other, which is exactly why [needsSportTypeFix] exists.
+ */
+object StravaSportMapping {
+
+ /** Desired Strava `sport_type` for the follow-up activity update. Never null — unknown → Workout. */
+ fun toStravaType(type: String): String = when (type) {
+ "run" -> "Run"
+ "walk" -> "Walk"
+ "cycle" -> "Ride"
+ "gym" -> "WeightTraining"
+ "squash" -> "Squash"
+ "yoga" -> "Yoga"
+ "hike" -> "Hike"
+ else -> "Workout" // dance, sport, and any unknown custom type
+ }
+
+ /**
+ * True when uploading the TCX sport alone won't produce the desired `sport_type`, so the
+ * uploader must follow up with an activity update. Only run and cycle map losslessly.
+ */
+ fun needsSportTypeFix(type: String): Boolean = type != "run" && type != "cycle"
+
+ /** Human label used in the Strava activity name ("Morning Run"). */
+ fun displayLabel(type: String): String = when (type) {
+ "run" -> "Run"
+ "walk" -> "Walk"
+ "cycle" -> "Ride"
+ "gym" -> "Workout"
+ "squash" -> "Squash"
+ "yoga" -> "Yoga"
+ "hike" -> "Hike"
+ "dance" -> "Workout"
+ else -> "Activity"
+ }
+}
diff --git a/app/src/main/java/com/pulseloop/strava/StravaTCXBuilder.kt b/app/src/main/java/com/pulseloop/strava/StravaTCXBuilder.kt
new file mode 100644
index 0000000..4ad7f86
--- /dev/null
+++ b/app/src/main/java/com/pulseloop/strava/StravaTCXBuilder.kt
@@ -0,0 +1,165 @@
+package com.pulseloop.strava
+
+import com.pulseloop.data.entity.ActivityEventEntity
+import com.pulseloop.data.entity.ActivityGpsPointEntity
+import com.pulseloop.data.entity.ActivitySessionEntity
+import com.pulseloop.data.entity.MeasurementEntity
+
+/**
+ * Pure TCX generator for Strava uploads, ported from StravaTCXBuilder.swift (iOS #100). No DB
+ * access — the caller passes pre-fetched rows so the builder stays deterministic and testable.
+ */
+object StravaTCXBuilder {
+
+ /**
+ * Ring HR is sparse (spot reads), so a GPS trackpoint reuses the most recent HR sample
+ * at-or-before its own time — but only within this window; older samples are omitted rather
+ * than smeared across a gap.
+ */
+ const val HR_STALENESS_WINDOW_MS = 60_000L
+
+ /** A paused span, half-open. */
+ data class PauseInterval(val start: Long, val end: Long) {
+ fun contains(ts: Long) = ts in start..end
+ }
+
+ /**
+ * Builds the TCX document, or **null when there are no emittable trackpoints**. A TCX whose
+ * `