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 + * `` is empty is schema-invalid and Strava rejects it; the caller is expected to fall + * back to creating a manual activity so the workout still lands. + */ + fun build( + session: ActivitySessionEntity, + gpsPoints: List, + hrSamples: List, + pauseIntervals: List = emptyList(), + ): String? { + val sortedGps = gpsPoints.filter { it.accepted }.sortedBy { it.timestamp } + val sortedHR = hrSamples.filter { it.value > 0 }.sortedBy { it.timestamp } + + val trackpoints = when { + sortedGps.size >= 2 -> gpsTrackpoints(sortedGps, sortedHR, pauseIntervals) + sortedHR.isNotEmpty() -> indoorTrackpoints(sortedHR, pauseIntervals) + else -> emptyList() + } + if (trackpoints.isEmpty()) return null + + val startISO = iso8601(session.startedAt) + val ended = session.endedAt ?: session.startedAt + // Elapsed minus paused time — the previous version reported wall-clock, so a workout + // paused for 20 minutes showed 20 extra minutes of moving time on Strava. + val totalSeconds = maxOf(0.0, (ended - session.startedAt) / 1000.0 - session.totalPauseSeconds) + + return buildString { + appendLine("""""") + appendLine("""""") + appendLine(""" """) + appendLine(""" """) + // TCX types Activity/Id as xsd:dateTime — it is not an epoch counter. + appendLine(""" $startISO""") + appendLine(""" """) + appendLine(""" ${fmt1(totalSeconds)}""") + appendLine(""" ${fmt1(session.distanceMeters ?: 0.0)}""") + appendLine(""" ${(session.calories ?: 0.0).toInt()}""") + session.avgHeartRate?.let { + appendLine(""" ${Math.round(it)}""") + } + session.maxHeartRate?.let { + appendLine(""" ${Math.round(it)}""") + } + appendLine(""" Active""") + // Required by the ActivityLap schema; omitting it makes the document invalid. + appendLine(""" Manual""") + appendLine(""" """) + trackpoints.forEach { appendLine(it) } + appendLine(""" """) + appendLine(""" """) + appendLine(""" """) + appendLine(""" """) + appendLine("""""") + } + } + + /** + * Pairs each `paused` event with the next `resumed`; an unpaired trailing `paused` closes at + * [endedAt]. Kind strings match what the activity recorder writes. + */ + fun pauseIntervals(events: List, endedAt: Long): List { + val intervals = mutableListOf() + var openPause: Long? = null + for (event in events.sortedBy { it.timestamp }) { + when (event.kind) { + "paused" -> if (openPause == null) openPause = event.timestamp + "resumed" -> { + val start = openPause + if (start != null && event.timestamp > start) { + intervals.add(PauseInterval(start, event.timestamp)) + } + openPause = null + } + } + } + openPause?.let { if (endedAt > it) intervals.add(PauseInterval(it, endedAt)) } + return intervals + } + + // ── Trackpoints ───────────────────────────────────────────────────────────── + + /** GPS mode: one trackpoint per fix, HR merged as a step function within the staleness window. */ + private fun gpsTrackpoints( + gps: List, + hr: List, + pauses: List, + ): List { + val lines = mutableListOf() + var hrIndex = 0 + for (point in gps) { + while (hrIndex < hr.size && hr[hrIndex].timestamp <= point.timestamp) hrIndex++ + val current = if (hrIndex > 0) hr[hrIndex - 1] else null + if (pauses.any { it.contains(point.timestamp) }) continue + + lines.add(""" """) + lines.add(""" """) + lines.add(""" """) + lines.add(""" ${fmt6(point.latitude)}""") + lines.add(""" ${fmt6(point.longitude)}""") + lines.add(""" """) + point.altitude?.let { lines.add(""" ${fmt1(it)}""") } + if (current != null && point.timestamp - current.timestamp <= HR_STALENESS_WINDOW_MS) { + lines.add(""" ${Math.round(current.value)}""") + } + lines.add(""" """) + } + return lines + } + + /** Indoor mode (fewer than 2 GPS fixes): one trackpoint per HR sample, no Position. */ + private fun indoorTrackpoints(hr: List, pauses: List): List { + val lines = mutableListOf() + for (sample in hr) { + if (pauses.any { it.contains(sample.timestamp) }) continue + lines.add(""" """) + lines.add(""" """) + lines.add(""" ${Math.round(sample.value)}""") + lines.add(""" """) + } + return lines + } + + // TCX v2 only allows "Running" | "Biking" | "Other" for the Activity Sport attribute — which is + // why most types need the follow-up sport_type update in StravaSportMapping. + private fun sportName(type: String): String = when (type) { + "run" -> "Running" + "cycle" -> "Biking" + else -> "Other" + } + + private fun fmt1(v: Double) = String.format(java.util.Locale.US, "%.1f", v) + private fun fmt6(v: Double) = String.format(java.util.Locale.US, "%.6f", v) + + private fun iso8601(epochMs: Long): String { + val sdf = java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", java.util.Locale.US) + sdf.timeZone = java.util.TimeZone.getTimeZone("UTC") + return sdf.format(java.util.Date(epochMs)) + } +} diff --git a/app/src/main/java/com/pulseloop/strava/StravaTokenStore.kt b/app/src/main/java/com/pulseloop/strava/StravaTokenStore.kt new file mode 100644 index 0000000..6ae620f --- /dev/null +++ b/app/src/main/java/com/pulseloop/strava/StravaTokenStore.kt @@ -0,0 +1,91 @@ +package com.pulseloop.strava + +import android.content.Context +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKeys +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +class StravaTokenStore(context: Context) { + private val masterKey = MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC) + private val prefs = EncryptedSharedPreferences.create( + "pulseloop_strava", + masterKey, + context, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, + ) + + private val json = Json { ignoreUnknownKeys = true } + + fun get(): StravaTokens? { + val raw = prefs.getString(KEY_TOKENS, null) ?: return null + return try { json.decodeFromString(raw) } catch (_: Exception) { null } + } + + fun save(tokens: StravaTokens) { + val edit = prefs.edit().putString(KEY_TOKENS, json.encodeToString(tokens)) + // Stamp the moment auto-upload became live, once. Everything that finished before this is + // history the user never asked us to publish (iOS's `automaticSince`). + if (!prefs.contains(KEY_AUTO_SINCE)) edit.putLong(KEY_AUTO_SINCE, System.currentTimeMillis()) + edit.apply() + } + + /** When auto-upload was switched on, or null when never connected. */ + fun autoUploadSince(): Long? = + if (prefs.contains(KEY_AUTO_SINCE)) prefs.getLong(KEY_AUTO_SINCE, 0L) else null + + fun clear() { + prefs.edit() + .remove(KEY_TOKENS) + .remove(KEY_PENDING_STATE) + .remove(KEY_AUTO_SINCE) + .remove(KEY_LAST_ERROR) + .apply() + } + + val isConnected: Boolean get() = get() != null + + /** + * The OAuth CSRF state for an authorization currently in flight. Persisted because the browser + * (or the Strava app) takes the foreground for the duration, and Android may kill this process + * while it's away — an in-memory value would be gone by the time the redirect comes back. + * `commit()`, not `apply()`: we are about to hand control to another app and may not survive + * long enough for an async write to land. + */ + @Suppress("ApplySharedPref") + fun savePendingAuthState(state: String) { + prefs.edit().putString(KEY_PENDING_STATE, state).commit() + } + + /** Read and clear in one step — a state may only be redeemed once. */ + fun takePendingAuthState(): String? { + val state = prefs.getString(KEY_PENDING_STATE, null) + if (state != null) prefs.edit().remove(KEY_PENDING_STATE).apply() + return state + } + + /** + * Why the last connect attempt failed. The OAuth callback lands in `MainActivity`, not in the + * settings screen, so the failure has to be left somewhere the screen can pick it up — without + * this the user just sees the Connect button do nothing. + */ + fun saveLastError(message: String) { + prefs.edit().putString(KEY_LAST_ERROR, message).apply() + } + + fun lastError(): String? = prefs.getString(KEY_LAST_ERROR, null) + + fun clearLastError() { + prefs.edit().remove(KEY_LAST_ERROR).apply() + } + + companion object { + private const val KEY_TOKENS = "strava_tokens" + private const val KEY_PENDING_STATE = "strava_pending_auth_state" + private const val KEY_LAST_ERROR = "strava_last_error" + private const val KEY_AUTO_SINCE = "strava_auto_upload_since" + /** The EncryptedSharedPreferences file, so a data reset knows to wipe it too. */ + const val PREFS_NAME = "pulseloop_strava" + } +} diff --git a/app/src/main/java/com/pulseloop/strava/StravaUploader.kt b/app/src/main/java/com/pulseloop/strava/StravaUploader.kt new file mode 100644 index 0000000..24d4dda --- /dev/null +++ b/app/src/main/java/com/pulseloop/strava/StravaUploader.kt @@ -0,0 +1,289 @@ +package com.pulseloop.strava + +import com.pulseloop.data.PulseLoopDatabase +import com.pulseloop.data.entity.ActivitySessionEntity +import com.pulseloop.ring.MeasurementKind +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import okhttp3.MediaType.Companion.toMediaType + +/** + * Uploads finished workouts to Strava as TCX, falling back to a manual activity when a session has + * nothing emittable. Ported from StravaUploadService.swift (iOS #100). + */ +object StravaUploader { + + private const val API_BASE = "https://www.strava.com/api/v3" + private const val TAG = "StravaUploader" + private const val MAX_POLL_ATTEMPTS = 15 + private const val POLL_INTERVAL_MS = 2_000L + private const val SPORT_FIX_RETRY_MS = 3_000L + /** How far back the automatic pass will look. Older sessions are manual-upload only. */ + private const val AUTO_SCAN_LIMIT = 20 + + private val json = Json { ignoreUnknownKeys = true } + + /** Outcome of one upload. Failures carry a message the settings screen can show. */ + sealed interface Result { + data class Success(val activityId: Long) : Result + data class Failure(val message: String) : Result + } + + suspend fun upload( + session: ActivitySessionEntity, + db: PulseLoopDatabase, + tokenStore: StravaTokenStore, + ): Result { + val tokens = tokenStore.get() ?: return Result.Failure("Not connected to Strava.") + val name = activityName(session) + + val gpsPoints = db.activityGpsPointDao().forSession(session.id) + // Android has no per-session ActivitySample link table (iOS's `ActivityRepository.samples`); + // the shared `measurements` table windowed to the session is the equivalent here, and is + // how ActivityAggregates.recompute reads a workout's HR too. + val hrEnd = session.endedAt ?: System.currentTimeMillis() + val hrSamples = db.measurementDao().range(MeasurementKind.HEART_RATE.name, session.startedAt, hrEnd) + + // Pause *intervals* would let us drop trackpoints recorded while paused, but Android never + // writes the activity_events table, so there are none to read. `totalPauseSeconds` is + // maintained, and the builder already subtracts it from TotalTimeSeconds. + val tcx = StravaTCXBuilder.build(session, gpsPoints, hrSamples, pauseIntervals = emptyList()) + + return if (tcx != null) { + uploadTcx(session, tcx, name, tokens, tokenStore) + } else { + // No accepted GPS route and no HR: a TCX would carry an empty , which is + // schema-invalid and gets rejected. Create a bare manual activity so the workout still + // lands — it carries the exact sport_type, so no follow-up update is needed. + createManualActivity(session, name, tokens, tokenStore) + } + } + + private suspend fun uploadTcx( + session: ActivitySessionEntity, + tcx: String, + name: String, + tokens: StravaTokens, + tokenStore: StravaTokenStore, + ): Result { + val body = okhttp3.MultipartBody.Builder() + .setType(okhttp3.MultipartBody.FORM) + .addFormDataPart( + "file", "${session.id}.tcx", + okhttp3.RequestBody.create("application/xml".toMediaType(), tcx.toByteArray(Charsets.UTF_8)), + ) + .addFormDataPart("data_type", "tcx") + .addFormDataPart("name", name) + .addFormDataPart("external_id", session.id) + .apply { + session.notes?.takeIf { it.isNotBlank() }?.let { addFormDataPart("description", it) } + if (!session.useGps) addFormDataPart("trainer", "1") + } + .build() + + val response = StravaAuth.authenticatedRequest(tokens, tokenStore, "POST", "$API_BASE/uploads", body) + val respBody = response.body?.string().orEmpty() + + if (response.code == 429) return Result.Failure("Strava rate limit reached. Try again later.") + if (response.code == 409) { + val dupId = parseDuplicate(respBody) + return if (dupId != null) { + fixSportType(dupId, session.type, tokens, tokenStore) + Result.Success(dupId) + } else { + Result.Failure("Already on Strava.") + } + } + if (!response.isSuccessful) { + return Result.Failure("Strava upload failed (HTTP ${response.code}): ${respBody.take(200)}") + } + + val initial = runCatching { json.decodeFromString(respBody) }.getOrNull() + ?: return Result.Failure("Unexpected response from Strava.") + + return when (val outcome = pollUpload(initial, tokens, tokenStore)) { + is Result.Success -> { + fixSportType(outcome.activityId, session.type, tokens, tokenStore) + outcome + } + is Result.Failure -> outcome + } + } + + /** + * Polls Strava's async processing until it yields an activity id. Checks the initial response + * first, so an already-terminal upload never sleeps. + */ + private suspend fun pollUpload( + initial: UploadStatus, + tokens: StravaTokens, + tokenStore: StravaTokenStore, + ): Result { + var status = initial + var attempts = 0 + while (true) { + terminalResult(status)?.let { return it } + if (++attempts > MAX_POLL_ATTEMPTS) { + return Result.Failure("Timed out waiting for Strava to process the upload.") + } + kotlinx.coroutines.delay(POLL_INTERVAL_MS) + val response = StravaAuth.authenticatedRequest( + tokens, tokenStore, "GET", "$API_BASE/uploads/${status.id}", + ) + val body = response.body?.string() ?: continue + status = runCatching { json.decodeFromString(body) }.getOrNull() ?: continue + } + } + + /** null = still processing. A duplicate resolves to the existing activity — it *is* on Strava. */ + private fun terminalResult(status: UploadStatus): Result? { + val error = status.error + if (!error.isNullOrEmpty()) { + if (error.contains("duplicate", ignoreCase = true)) { + val dupId = parseDuplicate(error) + return if (dupId != null) Result.Success(dupId) else Result.Failure("Already on Strava.") + } + return Result.Failure("Strava could not process the upload: $error") + } + return status.activity_id?.let { Result.Success(it) } + } + + private suspend fun createManualActivity( + session: ActivitySessionEntity, + name: String, + tokens: StravaTokens, + tokenStore: StravaTokenStore, + ): Result { + val ended = session.endedAt ?: session.startedAt + val elapsed = maxOf(0L, ((ended - session.startedAt) / 1000.0 - session.totalPauseSeconds).toLong()) + val form = okhttp3.FormBody.Builder() + .add("name", name) + .add("sport_type", StravaSportMapping.toStravaType(session.type)) + .add("start_date_local", localIso8601(session.startedAt)) + .add("elapsed_time", elapsed.toString()) + .apply { + session.notes?.takeIf { it.isNotBlank() }?.let { add("description", it) } + session.distanceMeters?.let { add("distance", it.toString()) } + if (!session.useGps) add("trainer", "1") + } + .build() + + val response = StravaAuth.authenticatedRequest(tokens, tokenStore, "POST", "$API_BASE/activities", form) + val body = response.body?.string().orEmpty() + if (!response.isSuccessful) { + return Result.Failure("Strava rejected the activity (HTTP ${response.code}): ${body.take(200)}") + } + val summary = runCatching { json.decodeFromString(body) }.getOrNull() + ?: return Result.Failure("Unexpected response from Strava.") + return Result.Success(summary.id) + } + + /** + * TCX can only carry Running/Biking/Other, so everything else needs a follow-up `sport_type` + * update. Strava re-derives the type from the file shortly after processing and can overwrite + * an immediate update, so verify what it reports back and retry once. Best-effort — the upload + * already succeeded, so a final failure is only logged. + */ + private suspend fun fixSportType( + activityId: Long, + type: String, + tokens: StravaTokens, + tokenStore: StravaTokenStore, + ) { + if (!StravaSportMapping.needsSportTypeFix(type)) return + val desired = StravaSportMapping.toStravaType(type) + val body = okhttp3.RequestBody.create("application/json".toMediaType(), """{"sport_type":"$desired"}""") + for (attempt in 1..2) { + if (attempt > 1) kotlinx.coroutines.delay(SPORT_FIX_RETRY_MS) + val response = StravaAuth.authenticatedRequest( + tokens, tokenStore, "PUT", "$API_BASE/activities/$activityId", body, + ) + val raw = response.body?.string() + if (!response.isSuccessful) { + android.util.Log.w(TAG, "sport_type update failed (attempt $attempt): HTTP ${response.code}") + continue + } + val applied = raw?.let { runCatching { json.decodeFromString(it).sport_type }.getOrNull() } + // null = unparseable response; assume the 2xx meant it stuck. + if (applied == null || applied == desired) return + android.util.Log.w(TAG, "sport_type fix attempt $attempt: Strava reports $applied, wanted $desired") + } + } + + /** + * The automatic pass. Uploads every not-yet-uploaded finished session that ended after + * auto-upload was switched on, oldest first. Returns how many landed. + * + * Two things it deliberately does *not* do: + * - **Back-fill history.** Sessions that finished before the account was connected are skipped + * (iOS's `automaticSince`). Without that, connecting Strava would push up to 20 old workouts + * to a public feed the moment it was enabled. + * - **Skip past a failure.** A failing session stops the pass so it is retried next time, + * rather than being silently left behind while newer ones go up (iOS's contiguous-advance + * watermark). + */ + suspend fun uploadAuto(db: PulseLoopDatabase, tokenStore: StravaTokenStore): Int { + if (!tokenStore.isConnected) return 0 + val since = tokenStore.autoUploadSince() ?: return 0 + var uploaded = 0 + val pending = db.activitySessionDao().recent(AUTO_SCAN_LIMIT) + .filter { it.statusRaw == "finished" && it.stravaActivityId == null && it.endedAt != null } + .sortedBy { it.endedAt } + + for (session in pending) { + if ((session.endedAt ?: 0L) < since) continue // predates the connection + when (val result = upload(session, db, tokenStore)) { + is Result.Success -> { + db.activitySessionDao().upsert(session.copy(stravaActivityId = result.activityId)) + uploaded++ + } + is Result.Failure -> { + android.util.Log.w(TAG, "auto upload stopped at ${session.id}: ${result.message}") + tokenStore.saveLastError(result.message) + return uploaded + } + } + } + return uploaded + } + + /** Strava-style time-of-day name, e.g. "Morning Run" — deliberately unbranded, matching iOS. */ + fun activityName(session: ActivitySessionEntity): String { + val hour = java.util.Calendar.getInstance() + .apply { timeInMillis = session.startedAt } + .get(java.util.Calendar.HOUR_OF_DAY) + val period = when (hour) { + in 4..10 -> "Morning" + in 11..13 -> "Lunch" + in 14..17 -> "Afternoon" + in 18..21 -> "Evening" + else -> "Night" + } + return "$period ${StravaSportMapping.displayLabel(session.type)}" + } + + /** + * Extracts the existing activity id from a duplicate error, e.g. + * "workout.tcx is a duplicate of activity 123456". + */ + internal fun parseDuplicate(message: String): Long? = + Regex("duplicate of (?:activity )?(\\d+)", RegexOption.IGNORE_CASE) + .find(message)?.groupValues?.get(1)?.toLongOrNull() + + /** Local wall-clock with the device's UTC offset, as Strava's `start_date_local` expects. */ + private fun localIso8601(epochMs: Long): String { + val sdf = java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssXXX", java.util.Locale.US) + return sdf.format(java.util.Date(epochMs)) + } + + @Serializable + private data class UploadStatus( + val id: Long, + val activity_id: Long? = null, + val error: String? = null, + val status: String? = null, + ) + + @Serializable + private data class ActivitySummary(val id: Long, val sport_type: String? = null) +} diff --git a/app/src/main/java/com/pulseloop/ui/PulseLoopApp.kt b/app/src/main/java/com/pulseloop/ui/PulseLoopApp.kt index 513c99e..3e37251 100644 --- a/app/src/main/java/com/pulseloop/ui/PulseLoopApp.kt +++ b/app/src/main/java/com/pulseloop/ui/PulseLoopApp.kt @@ -572,7 +572,28 @@ fun PulseLoopApp() { GoalsSettingsScreen(coordinator, onBack = { navController.popBackStack() }) } paddedComposable("settings/privacy") { - PrivacyDataSettingsScreen(onBack = { navController.popBackStack() }) + PrivacyDataSettingsScreen( + onBack = { navController.popBackStack() }, + coordinator = coordinator, + bleClient = bleClient, + onNavigateToOnboarding = { + navController.navigate("onboarding") { + popUpTo(0) { inclusive = true } + } + }, + ) + } + paddedComposable("settings/strava") { + StravaSettingsScreen(onBack = { navController.popBackStack() }) + } + paddedComposable("settings/nutrition") { + NutritionSettingsScreen( + onBack = { navController.popBackStack() }, + onNavigateToNutrition = { navController.navigate("nutrition") }, + ) + } + paddedComposable("nutrition") { + NutritionScreen(onBack = { navController.popBackStack() }) } paddedComposable("settings/about") { AboutSettingsScreen( diff --git a/app/src/main/java/com/pulseloop/ui/screens/NutritionScreen.kt b/app/src/main/java/com/pulseloop/ui/screens/NutritionScreen.kt new file mode 100644 index 0000000..50bfecb --- /dev/null +++ b/app/src/main/java/com/pulseloop/ui/screens/NutritionScreen.kt @@ -0,0 +1,234 @@ +package com.pulseloop.ui.screens + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.ChevronLeft +import androidx.compose.material.icons.filled.ChevronRight +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.pulseloop.data.PulseLoopDatabase +import com.pulseloop.data.dao.MealTotals +import com.pulseloop.data.entity.MealEntryEntity +import com.pulseloop.data.entity.UserGoalEntity +import com.pulseloop.ui.theme.PulseColors +import kotlin.math.roundToInt +import kotlinx.coroutines.launch +import java.util.Calendar + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun NutritionScreen(onBack: () -> Unit) { + val context = androidx.compose.ui.platform.LocalContext.current + val scope = rememberCoroutineScope() + val db = remember { PulseLoopDatabase.getInstance(context) } + + var dayOffset by remember { mutableIntStateOf(0) } + val calendar = remember { Calendar.getInstance() } + val todayStart = remember(dayOffset) { + calendar.apply { timeInMillis = System.currentTimeMillis(); add(Calendar.DAY_OF_YEAR, -dayOffset) }.run { + set(Calendar.HOUR_OF_DAY, 0); set(Calendar.MINUTE, 0); set(Calendar.SECOND, 0); set(Calendar.MILLISECOND, 0) + timeInMillis + } + } + val dateLabel = remember(todayStart) { + java.text.SimpleDateFormat("EEE, MMM d", java.util.Locale.US).format(java.util.Date(todayStart)) + } + + var meals by remember { mutableStateOf>(emptyList()) } + var totals by remember { mutableStateOf>(emptyList()) } + var goal by remember { mutableStateOf(null) } + + fun reload() { + scope.launch { + meals = db.mealEntryDao().byDay(todayStart) + totals = db.mealEntryDao().dayTotals(todayStart) + } + } + LaunchedEffect(Unit) { goal = db.userGoalDao().get() } + LaunchedEffect(dayOffset) { reload() } + + var showAddDialog by remember { mutableStateOf(false) } + + Scaffold( + containerColor = PulseColors.background, + topBar = { + TopAppBar( + title = { Text("Nutrition") }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") + } + }, + colors = TopAppBarDefaults.topAppBarColors(containerColor = PulseColors.background), + ) + }, + ) { padding -> + LazyColumn( + Modifier.fillMaxSize().padding(padding), + verticalArrangement = Arrangement.spacedBy(12.dp), + contentPadding = PaddingValues(16.dp), + ) { + // Day navigation — left goes to older dates (higher offset), right goes to today (offset 0). + item { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically) { + IconButton(onClick = { dayOffset++ }) { Icon(Icons.Filled.ChevronLeft, "Previous") } + Text(dateLabel, fontWeight = FontWeight.SemiBold, fontSize = 16.sp) + IconButton(onClick = { if (dayOffset > 0) dayOffset-- }, enabled = dayOffset > 0) { Icon(Icons.Filled.ChevronRight, "Next") } + } + } + + // Calorie gauge + item { + val totalCal = totals.sumOf { it.totalCal } + val goalCal = goal?.intakeCalories ?: 2000.0 + val pct = if (goalCal > 0) (totalCal / goalCal).coerceAtMost(1.0) else 0.0 + Card(Modifier.fillMaxWidth(), shape = RoundedCornerShape(20.dp), colors = CardDefaults.cardColors(containerColor = PulseColors.card)) { + Column(Modifier.padding(20.dp), horizontalAlignment = Alignment.CenterHorizontally) { + Text("${totalCal.roundToInt()} kcal", fontSize = 32.sp, fontWeight = FontWeight.Bold) + Text("of ${goalCal.roundToInt()}", color = PulseColors.textMuted) + Spacer(Modifier.height(8.dp)) + LinearProgressIndicator( + progress = { pct.toFloat() }, + modifier = Modifier.fillMaxWidth().height(8.dp), + color = PulseColors.calories, + trackColor = PulseColors.cardSoft, + ) + Spacer(Modifier.height(8.dp)) + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly) { + MacroBar("Protein", totals.sumOf { it.totalP }, goal?.intakeProteinG ?: 150.0, PulseColors.heartRate) + MacroBar("Carbs", totals.sumOf { it.totalC }, goal?.intakeCarbsG ?: 250.0, PulseColors.calories) + MacroBar("Fat", totals.sumOf { it.totalF }, goal?.intakeFatG ?: 65.0, PulseColors.warning) + } + } + } + } + + // Meals grouped by type + val mealTypes = listOf("breakfast", "lunch", "dinner", "snack") + for (type in mealTypes) { + val typeMeals = meals.filter { it.mealTypeRaw == type } + if (typeMeals.isNotEmpty()) { + item { + Text(type.replaceFirstChar { it.uppercase() }, fontWeight = FontWeight.SemiBold, fontSize = 14.sp, color = PulseColors.textSecondary) + } + items(typeMeals, key = { it.id }) { meal -> + Card(Modifier.fillMaxWidth(), shape = RoundedCornerShape(14.dp), colors = CardDefaults.cardColors(containerColor = PulseColors.card)) { + Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Text(meal.name, fontWeight = FontWeight.Medium) + Text("${meal.calories.roundToInt()} kcal · P${meal.proteinG.roundToInt()} C${meal.carbsG.roundToInt()} F${meal.fatG.roundToInt()}", fontSize = 12.sp, color = PulseColors.textMuted) + } + IconButton(onClick = { + scope.launch { + db.mealEntryDao().deleteById(meal.id) + reload() + } + }) { + Icon(Icons.Filled.Delete, "Delete", tint = PulseColors.danger, modifier = Modifier.size(18.dp)) + } + } + } + } + } + } + + // Add button + item { + OutlinedButton( + onClick = { showAddDialog = true }, + modifier = Modifier.fillMaxWidth(), + ) { + Icon(Icons.Filled.Add, null, Modifier.size(18.dp)) + Spacer(Modifier.width(6.dp)) + Text("Log Meal") + } + } + } + } + + if (showAddDialog) { + MealLogDialog( + onDismiss = { showAddDialog = false }, + onSave = { name, type, cal, p, c, f -> + scope.launch { + db.mealEntryDao().upsert(MealEntryEntity( + date = todayStart, timestamp = System.currentTimeMillis(), + name = name, mealTypeRaw = type, + calories = cal, proteinG = p, carbsG = c, fatG = f, + )) + reload() + } + showAddDialog = false + }, + ) + } +} + +@Composable +private fun MacroBar(label: String, value: Double, goal: Double, color: Color) { + val pct = if (goal > 0) (value / goal).coerceAtMost(1.0) else 0.0 + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text(label, fontSize = 10.sp, color = PulseColors.textMuted) + Text("${value.roundToInt()}g", fontSize = 13.sp, fontWeight = FontWeight.SemiBold) + LinearProgressIndicator( + progress = { pct.toFloat() }, + modifier = Modifier.width(60.dp).height(4.dp), + color = color, trackColor = PulseColors.cardSoft, + ) + } +} + +@Composable +fun MealLogDialog( + onDismiss: () -> Unit, + onSave: (name: String, type: String, cal: Double, p: Double, c: Double, f: Double) -> Unit, +) { + var name by remember { mutableStateOf("") } + var type by remember { mutableStateOf("snack") } + var cal by remember { mutableStateOf("") } + var protein by remember { mutableStateOf("") } + var carbs by remember { mutableStateOf("") } + var fat by remember { mutableStateOf("") } + val valid = name.isNotBlank() && (cal.toDoubleOrNull() ?: 0.0) > 0 + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Log Meal") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedTextField(value = name, onValueChange = { name = it }, label = { Text("Name") }, modifier = Modifier.fillMaxWidth(), singleLine = true) + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + listOf("breakfast", "lunch", "dinner", "snack").forEach { t -> + FilterChip(selected = type == t, onClick = { type = t }, label = { Text(t.replaceFirstChar { it.uppercase() }) }) + } + } + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedTextField(value = cal, onValueChange = { cal = it }, label = { Text("kcal") }, modifier = Modifier.weight(1f), singleLine = true) + OutlinedTextField(value = protein, onValueChange = { protein = it }, label = { Text("P(g)") }, modifier = Modifier.weight(1f), singleLine = true) + } + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedTextField(value = carbs, onValueChange = { carbs = it }, label = { Text("C(g)") }, modifier = Modifier.weight(1f), singleLine = true) + OutlinedTextField(value = fat, onValueChange = { fat = it }, label = { Text("F(g)") }, modifier = Modifier.weight(1f), singleLine = true) + } + } + }, + confirmButton = { + TextButton(onClick = { + onSave(name, type, cal.toDoubleOrNull() ?: 0.0, protein.toDoubleOrNull() ?: 0.0, carbs.toDoubleOrNull() ?: 0.0, fat.toDoubleOrNull() ?: 0.0) + }, enabled = valid) { Text("Save") } + }, + dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }, + ) +} diff --git a/app/src/main/java/com/pulseloop/ui/screens/SettingsScreen.kt b/app/src/main/java/com/pulseloop/ui/screens/SettingsScreen.kt index c01bb16..1b636a0 100644 --- a/app/src/main/java/com/pulseloop/ui/screens/SettingsScreen.kt +++ b/app/src/main/java/com/pulseloop/ui/screens/SettingsScreen.kt @@ -126,6 +126,9 @@ fun SettingsScreen( SettingsRowItem(Icons.Filled.TrackChanges, PulseColors.readiness, "Goals") { navigate("settings/goals") }, + SettingsRowItem(Icons.Filled.RestaurantMenu, PulseColors.calories, "Nutrition") { + navigate("settings/nutrition") + }, ), ) @@ -149,6 +152,9 @@ fun SettingsScreen( add(SettingsRowItem(Icons.Filled.Shield, PulseColors.success, "Privacy & Data") { navigate("settings/privacy") }) + add(SettingsRowItem(Icons.Filled.TrendingUp, PulseColors.calories, "Strava") { + navigate("settings/strava") + }) add(SettingsRowItem(Icons.Filled.Info, PulseColors.textMuted, "About PulseLoop") { navigate("settings/about") }) diff --git a/app/src/main/java/com/pulseloop/ui/screens/SettingsSubScreens.kt b/app/src/main/java/com/pulseloop/ui/screens/SettingsSubScreens.kt index 3c607eb..e0f86ba 100644 --- a/app/src/main/java/com/pulseloop/ui/screens/SettingsSubScreens.kt +++ b/app/src/main/java/com/pulseloop/ui/screens/SettingsSubScreens.kt @@ -40,6 +40,7 @@ import com.pulseloop.coach.config.MiniMaxModel import com.pulseloop.coach.config.OpenRouterModel import com.pulseloop.data.DemoDataSeeder import com.pulseloop.data.PulseLoopDatabase +import com.pulseloop.data.entity.UserGoalEntity import com.pulseloop.notifications.CoachNotifications import com.pulseloop.ring.MeasurementKind import com.pulseloop.ring.RingBLEClient @@ -1591,20 +1592,214 @@ private fun BatteryHistorySection(db: PulseLoopDatabase) { // MARK: - Privacy & Data +/** Destructive data-action options for the Privacy & Data screen. */ +private sealed class ResetAction { + data object UnpairRing : ResetAction() + data object ResetAppData : ResetAction() + data object UnpairAndReset : ResetAction() + + val title: String get() = when (this) { + is UnpairRing -> "Unpair ring?" + is ResetAppData -> "Reset app data?" + is UnpairAndReset -> "Unpair ring & reset app data?" + } + val message: String get() = when (this) { + is UnpairRing -> "Unpair your ring? The ring will forget this phone; your data stays." + is ResetAppData -> "This permanently erases all your data — metrics, sleep, activity, coach history, settings, and saved API keys — and can't be undone." + is UnpairAndReset -> "This unpairs your ring, then permanently erases all your data — metrics, sleep, activity, coach history, settings, and saved API keys — and can't be undone." + } + val confirmLabel: String get() = when (this) { + is UnpairRing -> "Unpair ring" + is ResetAppData -> "Reset app data" + is UnpairAndReset -> "Unpair & reset" + } +} + /** - * Privacy & Data detail screen (iOS PrivacyDataView): demo-data controls (reseed + clear, - * moved here from About) and the diagnostics export with its anonymization opt-out. - * The mask toggle deliberately defaults ON for every visit and is never persisted off — - * an unmasked export (full BLE frames, health values) is a one-shot, explicit choice. + * Privacy & Data detail screen (iOS PrivacyDataView): grouped sections for diagnostics export, + * destructive App-data reset actions (Unpair Ring, Reset App Data, Unpair & Reset), + * data backup (export/import as JSON), and demo-data controls. Reflects the app's + * transparency/privacy ethos — everything here is local and explicit. */ @Composable -fun PrivacyDataSettingsScreen(onBack: () -> Unit) { +fun PrivacyDataSettingsScreen( + onBack: () -> Unit, + coordinator: RingSyncCoordinator? = null, + bleClient: RingBLEClient? = null, + onNavigateToOnboarding: () -> Unit = {}, +) { val context = LocalContext.current val scope = rememberCoroutineScope() var showSeedDialog by remember { mutableStateOf(false) } var showClearDialog by remember { mutableStateOf(false) } + /** Which destructive App-data action is awaiting confirmation. */ + var pendingReset by remember { mutableStateOf(null) } + + // Data export/import state. + var exportInProgress by remember { mutableStateOf(false) } + var importInProgress by remember { mutableStateOf(false) } + var showImportConfirm by remember { mutableStateOf(false) } + var pendingImportUri by remember { mutableStateOf(null) } + var statusMessage by remember { mutableStateOf(null) } + var showImportSuccess by remember { mutableStateOf(false) } + + val filePicker = rememberLauncherForActivityResult( + contract = ActivityResultContracts.OpenDocument() + ) { uri -> + if (uri != null) { + pendingImportUri = uri + showImportConfirm = true + } + } + + fun clearAllPreferences() { + listOf("pulseloop_secure", "ring_ble", "pulseloop_prefs").forEach { name -> + context.getSharedPreferences(name, 0).edit().clear().apply() + } + // Strava OAuth tokens live in their own EncryptedSharedPreferences file, so the loop above + // misses them — a "Reset App Data" that leaves the account connected is not a reset. iOS + // deletes the equivalent Keychain entry alongside the coach API keys for the same reason. + runCatching { com.pulseloop.strava.StravaTokenStore(context).clear() } + } + + fun performUnpair() { + scope.launch { + RingSyncWorker.cancel(context) + coordinator?.forgetRing { PulseLoopDatabase.getInstance(context).deviceDao().clear() } + ?: run { + bleClient?.forget() + PulseLoopDatabase.getInstance(context).deviceDao().clear() + } + } + } + + fun performResetAppData() { + scope.launch { + PulseLoopDatabase.getInstance(context).nukeAllTables() + clearAllPreferences() + onNavigateToOnboarding() + } + } + + fun performUnpairAndReset() { + scope.launch { + RingSyncWorker.cancel(context) + if (coordinator != null) { + coordinator.forgetRing { } + PulseLoopDatabase.getInstance(context).nukeAllTables() + } else { + bleClient?.forget() + PulseLoopDatabase.getInstance(context).nukeAllTables() + } + clearAllPreferences() + onNavigateToOnboarding() + } + } + SettingsSubScreen(title = "Privacy & Data", onBack = onBack) { + // Data Backup — export/import full app data as JSON. + Card(Modifier.fillMaxWidth()) { + Column(Modifier.padding(16.dp)) { + Text("Data Backup", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) + Spacer(Modifier.height(8.dp)) + Text( + "Export all your data to a single JSON file for backup, device migration, or external analysis. Import restores everything — replacing all current data.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(12.dp)) + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button( + onClick = { + exportInProgress = true + scope.launch { + try { + val db = PulseLoopDatabase.getInstance(context) + val uri = com.pulseloop.data.DataArchiveService.exportToFile(context, db) + if (uri != null) { + val shareIntent = Intent(Intent.ACTION_SEND).apply { + type = "application/json" + putExtra(Intent.EXTRA_STREAM, uri) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + context.startActivity(Intent.createChooser(shareIntent, "Export PulseLoop Data")) + } + } catch (_: Exception) { + statusMessage = "Export failed" + } + exportInProgress = false + } + }, + modifier = Modifier.weight(1f), + enabled = !exportInProgress, + ) { + if (exportInProgress) { + CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp) + Spacer(Modifier.width(8.dp)) + } + Text(if (exportInProgress) "Exporting…" else "Export All Data") + } + OutlinedButton( + onClick = { + filePicker.launch(arrayOf("application/json")) + }, + modifier = Modifier.weight(1f), + enabled = !importInProgress, + ) { + if (importInProgress) { + CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp) + Spacer(Modifier.width(8.dp)) + } + Text(if (importInProgress) "Importing…" else "Import Data") + } + } + } + } + + // App data — destructive reset/restore actions. + Card(Modifier.fillMaxWidth()) { + Column(Modifier.padding(16.dp)) { + Text("App Data", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) + Spacer(Modifier.height(8.dp)) + Text( + "Destructive actions: unpair the ring, factory-reset app data, or both.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(12.dp)) + OutlinedButton( + onClick = { pendingReset = ResetAction.UnpairRing }, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error), + ) { + Icon(Icons.Filled.BluetoothDisabled, null, Modifier.size(16.dp)) + Spacer(Modifier.width(4.dp)) + Text("Unpair Ring") + } + Spacer(Modifier.height(8.dp)) + OutlinedButton( + onClick = { pendingReset = ResetAction.ResetAppData }, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error), + ) { + Icon(Icons.Filled.DeleteForever, null, Modifier.size(16.dp)) + Spacer(Modifier.width(4.dp)) + Text("Reset App Data") + } + Spacer(Modifier.height(8.dp)) + OutlinedButton( + onClick = { pendingReset = ResetAction.UnpairAndReset }, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error), + ) { + Icon(Icons.Filled.Warning, null, Modifier.size(16.dp)) + Spacer(Modifier.width(4.dp)) + Text("Unpair & Reset") + } + } + } + // Demo data — Android-only (iOS seeds via the Simulator's SeedData). Card(Modifier.fillMaxWidth()) { Column(Modifier.padding(16.dp)) { @@ -1626,8 +1821,7 @@ fun PrivacyDataSettingsScreen(onBack: () -> Unit) { } } - // Diagnostics export — same exporter the Developer screen uses, surfaced here so - // sharing an anonymized log for a bug report doesn't require the 7-tap unlock. + // Diagnostics export. Card(Modifier.fillMaxWidth()) { Column(Modifier.padding(16.dp)) { var maskSensitive by remember { mutableStateOf(true) } @@ -1676,8 +1870,85 @@ fun PrivacyDataSettingsScreen(onBack: () -> Unit) { } } - if (showSeedDialog) { - AlertDialog( + // Confirmation/alert dialogs — mutually exclusive via when chain (prevent stacking). + when { + statusMessage != null -> { + val msg = statusMessage!! + AlertDialog( + onDismissRequest = { statusMessage = null }, + title = { Text("Error") }, + text = { Text(msg) }, + confirmButton = { + TextButton(onClick = { statusMessage = null }) { Text("OK") } + }, + ) + } + + showImportSuccess -> AlertDialog( + onDismissRequest = { showImportSuccess = false }, + title = { Text("Import Complete") }, + text = { Text("Your data has been restored from the backup.") }, + confirmButton = { + TextButton(onClick = { showImportSuccess = false }) { Text("OK") } + }, + ) + + showImportConfirm -> AlertDialog( + onDismissRequest = { showImportConfirm = false; pendingImportUri = null }, + title = { Text("Replace all data?") }, + text = { + Text("This permanently deletes everything currently in the app and replaces it with the contents of this file. This can't be undone.") + }, + confirmButton = { + TextButton(onClick = { + showImportConfirm = false + val uri = pendingImportUri ?: return@TextButton + pendingImportUri = null + importInProgress = true + scope.launch { + try { + val db = PulseLoopDatabase.getInstance(context) + com.pulseloop.data.DataArchiveService.importFile(context, uri, db) + showImportSuccess = true + } catch (_: Exception) { + statusMessage = "Import failed — the file may be corrupt or from a newer version." + } + importInProgress = false + } + }) { + Text("Delete old data & import", color = MaterialTheme.colorScheme.error) + } + }, + dismissButton = { + TextButton(onClick = { showImportConfirm = false; pendingImportUri = null }) { Text("Cancel") } + }, + ) + + pendingReset != null -> { + val action = pendingReset!! + AlertDialog( + onDismissRequest = { pendingReset = null }, + title = { Text(action.title) }, + text = { Text(action.message) }, + confirmButton = { + TextButton(onClick = { + pendingReset = null + when (action) { + is ResetAction.UnpairRing -> performUnpair() + is ResetAction.ResetAppData -> performResetAppData() + is ResetAction.UnpairAndReset -> performUnpairAndReset() + } + }) { + Text(action.confirmLabel, color = MaterialTheme.colorScheme.error) + } + }, + dismissButton = { + TextButton(onClick = { pendingReset = null }) { Text("Cancel") } + }, + ) + } + + showSeedDialog -> AlertDialog( onDismissRequest = { showSeedDialog = false }, title = { Text("Reseed Demo Data?") }, text = { Text("This will replace all existing demo data. Your synced ring data — including sleep history — will not be affected.") }, @@ -1686,7 +1957,6 @@ fun PrivacyDataSettingsScreen(onBack: () -> Unit) { showSeedDialog = false scope.launch { DemoDataSeeder.seed(PulseLoopDatabase.getInstance(context)) - // Freshly seeded demo data should show up on the widgets too. com.pulseloop.widgets.WidgetSnapshotPublisher.publish(context) } }) { Text("Reseed") } @@ -1695,10 +1965,8 @@ fun PrivacyDataSettingsScreen(onBack: () -> Unit) { TextButton(onClick = { showSeedDialog = false }) { Text("Cancel") } }, ) - } - if (showClearDialog) { - AlertDialog( + showClearDialog -> AlertDialog( onDismissRequest = { showClearDialog = false }, title = { Text("Clear Demo Data?") }, text = { @@ -1887,3 +2155,248 @@ fun AboutSettingsScreen(onOpenDebug: () -> Unit, onBack: () -> Unit) { private const val DEVELOPER_TAP_THRESHOLD = 7 private const val REPO_URL = "https://github.com/foureight84/PulseLoop" + +// MARK: - Nutrition + +@Composable +fun NutritionSettingsScreen(onBack: () -> Unit, onNavigateToNutrition: () -> Unit = {}) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val db = remember { PulseLoopDatabase.getInstance(context) } + var goal by remember { mutableStateOf(null) } + var goalLoaded by remember { mutableStateOf(false) } + LaunchedEffect(Unit) { goal = db.userGoalDao().get(); goalLoaded = true } + + SettingsSubScreen(title = "Nutrition", onBack = onBack) { + Card(Modifier.fillMaxWidth()) { + Column(Modifier.padding(16.dp)) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Text("Enable Nutrition Tracking", fontWeight = FontWeight.Medium) + Text("Log meals and track calories & macros", fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + Switch( + checked = goal?.nutritionEnabled == true, + enabled = goalLoaded, + onCheckedChange = { enabled -> + val g = goal ?: return@Switch + scope.launch { + goal = g.copy(nutritionEnabled = enabled).also { db.userGoalDao().upsert(it) } + } + }, + ) + } + } + } + + if (goal?.nutritionEnabled == true) { + Card(Modifier.fillMaxWidth()) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text("Daily Goals", fontWeight = FontWeight.SemiBold) + NutrientStepper("Calories (kcal)", goal?.intakeCalories ?: 2000.0, 50.0, 100.0, 10000.0) { v -> + scope.launch { goal = goal?.copy(intakeCalories = v)?.also { db.userGoalDao().upsert(it) } } + } + NutrientStepper("Protein (g)", goal?.intakeProteinG ?: 150.0, 5.0, 0.0, 500.0) { v -> + scope.launch { goal = goal?.copy(intakeProteinG = v)?.also { db.userGoalDao().upsert(it) } } + } + NutrientStepper("Carbs (g)", goal?.intakeCarbsG ?: 250.0, 5.0, 0.0, 800.0) { v -> + scope.launch { goal = goal?.copy(intakeCarbsG = v)?.also { db.userGoalDao().upsert(it) } } + } + NutrientStepper("Fat (g)", goal?.intakeFatG ?: 65.0, 5.0, 0.0, 300.0) { v -> + scope.launch { goal = goal?.copy(intakeFatG = v)?.also { db.userGoalDao().upsert(it) } } + } + OutlinedButton(onClick = { + val g = goal ?: return@OutlinedButton + scope.launch { + val cal = g.intakeCalories ?: 2000.0 + goal = g.copy(intakeProteinG = cal * 0.30 / 4.0, intakeCarbsG = cal * 0.40 / 4.0, intakeFatG = cal * 0.30 / 9.0) + .also { db.userGoalDao().upsert(it) } + } + }, modifier = Modifier.fillMaxWidth()) { + Text("Balance Macros (30/40/30)") + } + } + } + + Card(Modifier.fillMaxWidth()) { + Column(Modifier.padding(16.dp)) { + Text("Quick Access", fontWeight = FontWeight.SemiBold) + Spacer(Modifier.height(8.dp)) + Button( + onClick = onNavigateToNutrition, + modifier = Modifier.fillMaxWidth(), + ) { + Text("Open Nutrition Log") + } + } + } + } + } +} + +@Composable +private fun NutrientStepper(label: String, value: Double, step: Double, min: Double, max: Double, onChange: (Double) -> Unit) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { + Text(label, Modifier.weight(1f)) + Row(verticalAlignment = Alignment.CenterVertically) { + TextButton(onClick = { if (value > min) onChange((value - step).coerceAtLeast(min)) }) { Text("−", fontSize = 18.sp) } + Text("${value.roundToInt()}", fontWeight = FontWeight.Medium) + TextButton(onClick = { if (value < max) onChange((value + step).coerceAtMost(max)) }) { Text("+", fontSize = 18.sp) } + } + } +} + +// MARK: - Strava + +@Composable +fun StravaSettingsScreen(onBack: () -> Unit) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val tokenStore = remember { com.pulseloop.strava.StravaTokenStore(context) } + val isConfigured = com.pulseloop.strava.StravaAuth.isConfigured + var isConnected by remember { mutableStateOf(tokenStore.isConnected) } + var athleteName by remember { mutableStateOf(tokenStore.get()?.athleteName) } + var isUploading by remember { mutableStateOf(false) } + var statusMessage by remember { mutableStateOf(null) } + + // The OAuth callback lands in MainActivity, which writes tokens (or a failure message) into + // the store. Poll for either for 60 s after entering the screen, then stop. + LaunchedEffect(Unit) { + val deadline = System.currentTimeMillis() + 60_000L + while (System.currentTimeMillis() < deadline) { + tokenStore.lastError()?.let { error -> + statusMessage = error + tokenStore.clearLastError() + return@LaunchedEffect + } + val tokens = tokenStore.get() + if (tokens != null && !isConnected) { + isConnected = true + athleteName = tokens.athleteName + statusMessage = null + return@LaunchedEffect + } + kotlinx.coroutines.delay(1000) + } + } + + SettingsSubScreen(title = "Strava", onBack = onBack) { + if (!isConfigured) { + Card(Modifier.fillMaxWidth()) { + Column(Modifier.padding(16.dp)) { + Text("Strava Not Configured", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) + Spacer(Modifier.height(8.dp)) + Text( + "Add stravaClientId and stravaClientSecret to local.properties to enable Strava integration.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + return@SettingsSubScreen + } + + Card(Modifier.fillMaxWidth()) { + Column(Modifier.padding(16.dp)) { + Text("Strava Integration", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) + Spacer(Modifier.height(8.dp)) + Text( + if (isConnected) "Connected as $athleteName. Workouts sync automatically when finished." + else "Connect your Strava account to automatically upload workouts with GPS routes and heart rate data.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(12.dp)) + if (isConnected) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedButton( + onClick = { + isUploading = true + scope.launch { + try { + val db = PulseLoopDatabase.getInstance(context) + val count = com.pulseloop.strava.StravaUploader.uploadAuto(db, tokenStore) + // uploadAuto records why it stopped, if it stopped. + statusMessage = tokenStore.lastError()?.also { tokenStore.clearLastError() } + ?: when (count) { + 0 -> "Nothing new to upload" + 1 -> "Uploaded 1 workout" + else -> "Uploaded $count workouts" + } + } catch (e: Exception) { + statusMessage = "Upload failed: ${e.message ?: "unknown error"}" + } + isUploading = false + } + }, + modifier = Modifier.weight(1f), + enabled = !isUploading, + ) { + Text(if (isUploading) "Syncing…" else "Sync Now") + } + OutlinedButton( + onClick = { + scope.launch { + // Revoke on Strava's side first, so PulseLoop also disappears + // from the athlete's "My Apps" list — clearing local tokens + // alone leaves the authorization live forever. + tokenStore.get()?.let { + runCatching { com.pulseloop.strava.StravaAuth.deauthorize(it) } + } + tokenStore.clear() + isConnected = false + athleteName = null + statusMessage = "Disconnected from Strava" + } + }, + modifier = Modifier.weight(1f), + colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error), + ) { + Text("Disconnect") + } + } + } else { + Button( + onClick = { + statusMessage = null + tokenStore.clearLastError() + // Implicit ACTION_VIEW on /oauth/mobile/authorize: the Strava app takes + // it when installed, the browser otherwise (Strava's Android docs). + val authUrl = com.pulseloop.strava.StravaAuth.generateAuthUrl(tokenStore) + val intent = Intent(Intent.ACTION_VIEW, android.net.Uri.parse(authUrl)) + try { + context.startActivity(intent) + statusMessage = "Authorize in your browser — you'll return to PulseLoop automatically." + } catch (_: Exception) { + statusMessage = "Could not open browser" + } + }, + modifier = Modifier.fillMaxWidth(), + ) { + Text("Connect Strava") + } + } + } + } + + if (isConnected) { + Card(Modifier.fillMaxWidth()) { + Column(Modifier.padding(16.dp)) { + Text("Auto Upload", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) + Spacer(Modifier.height(4.dp)) + Text( + "Finished workouts are automatically uploaded to Strava. GPS routes, heart rate data, and sport type are included.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + + statusMessage?.let { msg -> + Card(Modifier.fillMaxWidth(), colors = CardDefaults.cardColors(containerColor = PulseColors.cardSoft)) { + Text(msg, modifier = Modifier.padding(16.dp), style = MaterialTheme.typography.bodyMedium) + } + } + } +} diff --git a/app/src/main/java/com/pulseloop/ui/screens/SleepScreen.kt b/app/src/main/java/com/pulseloop/ui/screens/SleepScreen.kt index a889b9c..0b92f6a 100644 --- a/app/src/main/java/com/pulseloop/ui/screens/SleepScreen.kt +++ b/app/src/main/java/com/pulseloop/ui/screens/SleepScreen.kt @@ -4,6 +4,7 @@ import androidx.compose.foundation.Canvas import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.pager.HorizontalPager @@ -30,8 +31,13 @@ import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.PathEffect import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.pulseloop.data.entity.SleepSessionEntity @@ -44,6 +50,7 @@ import com.pulseloop.service.SleepRangeKey import com.pulseloop.ui.components.CoachMessageCard import com.pulseloop.ui.theme.PulseColors import com.pulseloop.ui.viewmodels.SleepViewModel +import kotlin.math.roundToInt /** * Sleep dashboard — ported from SleepView.swift (+ DesignSystem sleep components): @@ -426,6 +433,10 @@ private fun LegendItem(label: String, color: Color) { /** * Step-style hypnogram: AWAKE/REM/LIGHT/DEEP lanes, glowing stage-colored segments, * dashed vertical transition connectors, time ticks below (SleepHypnogramView in Swift). + * + * iOS #131 port: lane labels now share the same laneFraction math as the Canvas bars + * (single source of truth), and a press-and-hold gesture shows a stage-readout pill + * with haptic feedback. */ @Composable private fun SleepHypnogram( @@ -451,6 +462,50 @@ private fun SleepHypnogram( clockTime(startTs + offset * 60_000L) } + // Shared plot insets — single source of truth for labels and canvas. + val plotInsets = androidx.compose.ui.unit.DpOffset(64.0.dp, 16.0.dp) + val plotBottom = 16.0.dp + val plotTrailing = 16.0.dp + + // Press-and-hold scrub state. + var scrubBlockIndex by remember { mutableIntStateOf(-1) } + var scrubMinute by remember { mutableIntStateOf(0) } + var isScrubbing by remember { mutableStateOf(false) } + var pillWidth by remember { mutableIntStateOf(0) } + val haptics = LocalHapticFeedback.current + + fun laneY(stage: String, plotHeightPx: Float) = plotHeightPx * (laneFrac[stage] ?: 0.62f) + fun xForMinute(minute: Int, plotWidthPx: Float) = + (minute.toFloat() / safeTotal).coerceIn(0f, 1f) * plotWidthPx + + fun minuteForX(touchX: Float, plotWidthPx: Float): Int { + if (plotWidthPx <= 0f) return 0 + return ((touchX / plotWidthPx) * safeTotal).roundToInt().coerceIn(0, safeTotal) + } + + fun blockIndexAtMinute(minute: Int): Int { + if (sorted.isEmpty()) return -1 + val exact = sorted.indexOfFirst { minute in it.startMinute until (it.startMinute + it.durationMinutes) } + if (exact >= 0) return exact + // Snap to nearest block by interval distance. + var best = 0 + var bestDist = Int.MAX_VALUE + for (i in sorted.indices) { + val b = sorted[i] + val dist = if (minute < b.startMinute) b.startMinute - minute + else minute - (b.startMinute + b.durationMinutes) + if (dist < bestDist) { bestDist = dist; best = i } + } + return best.coerceIn(0, sorted.lastIndex) + } + + fun readoutText(block: SleepStageBlockEntity): String { + val stage = block.stageRaw.replaceFirstChar { it.uppercase() } + val startTime = clockTime(startTs + block.startMinute * 60_000L) + val endTime = clockTime(startTs + (block.startMinute + block.durationMinutes) * 60_000L) + return "$stage · $startTime – $endTime" + } + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { Box( Modifier @@ -460,65 +515,168 @@ private fun SleepHypnogram( .background(Color(0xFF0F141F)) .border(1.dp, Color.White.copy(alpha = 0.06f), RoundedCornerShape(16.dp)), ) { - // Lane labels on the left. - Column( - Modifier.fillMaxHeight().padding(vertical = 14.dp, horizontal = 12.dp), - verticalArrangement = Arrangement.SpaceBetween, - ) { - lanes.forEach { stage -> - Text( - stage, - fontSize = 10.sp, fontWeight = FontWeight.SemiBold, - letterSpacing = 1.4.sp, color = stageColor(stage), - ) - } - } + var plotWidthPx by remember { mutableFloatStateOf(0f) } + var plotHeightPx by remember { mutableFloatStateOf(0f) } + // Plot area, inset to clear the labels. - Canvas( + Box( Modifier .fillMaxSize() - .padding(start = 64.dp, end = 16.dp, top = 16.dp, bottom = 16.dp), - ) { - if (sorted.isEmpty()) return@Canvas - fun laneY(stage: String) = size.height * (laneFrac[stage] ?: 0.62f) - fun x(minute: Int) = (minute.toFloat() / safeTotal).coerceIn(0f, 1f) * size.width - - // Dashed vertical connectors between consecutive blocks. - for (i in 1 until sorted.size) { - val prev = sorted[i - 1] - val cur = sorted[i] - val cx = x(cur.startMinute) - drawLine( - color = Color(0xFFD2CDFF).copy(alpha = 0.46f), - start = Offset(cx, laneY(prev.stageRaw)), - end = Offset(cx, laneY(cur.stageRaw)), - strokeWidth = 1.2.dp.toPx(), - cap = StrokeCap.Round, - pathEffect = PathEffect.dashPathEffect(floatArrayOf(2.5.dp.toPx(), 3.dp.toPx())), + .padding( + start = plotInsets.x, + end = plotTrailing, + top = plotInsets.y, + bottom = plotBottom, ) + .onSizeChanged { size -> + plotWidthPx = size.width.toFloat() + plotHeightPx = size.height.toFloat() + } + .pointerInput(sorted, plotWidthPx) { + if (plotWidthPx <= 0f) return@pointerInput + detectDragGesturesAfterLongPress( + onDragStart = { offset -> + val min = minuteForX(offset.x, plotWidthPx) + val idx = blockIndexAtMinute(min) + scrubBlockIndex = idx + scrubMinute = min + isScrubbing = true + haptics.performHapticFeedback(HapticFeedbackType.LongPress) + }, + onDrag = { _, dragAmount -> + val currentMin = minuteForX( + (xForMinute(scrubMinute, plotWidthPx) + dragAmount.x), + plotWidthPx, + ) + scrubMinute = currentMin + val newIdx = blockIndexAtMinute(currentMin) + if (newIdx != scrubBlockIndex) { + haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove) + } + scrubBlockIndex = newIdx + }, + onDragEnd = { + scrubBlockIndex = -1 + isScrubbing = false + }, + onDragCancel = { + scrubBlockIndex = -1 + isScrubbing = false + }, + ) + }, + ) { + Canvas(Modifier.fillMaxSize()) { + if (sorted.isEmpty()) return@Canvas + + // Dashed vertical connectors between consecutive blocks. + for (i in 1 until sorted.size) { + val prev = sorted[i - 1] + val cur = sorted[i] + val cx = xForMinute(cur.startMinute, size.width) + drawLine( + color = Color(0xFFD2CDFF).copy(alpha = 0.46f), + start = Offset(cx, laneY(prev.stageRaw, size.height)), + end = Offset(cx, laneY(cur.stageRaw, size.height)), + strokeWidth = 1.2.dp.toPx(), + cap = StrokeCap.Round, + pathEffect = PathEffect.dashPathEffect(floatArrayOf(2.5.dp.toPx(), 3.dp.toPx())), + ) + } + // Horizontal segment per block: soft halo underlay + solid line. + sorted.forEach { block -> + val y = laneY(block.stageRaw, size.height) + val startX = xForMinute(block.startMinute, size.width) + val endX = xForMinute(block.startMinute + block.durationMinutes, size.width) + .coerceAtLeast(startX) + val color = stageColor(block.stageRaw) + drawLine( + color = color.copy(alpha = 0.16f), + start = Offset(startX, y), end = Offset(endX, y), + strokeWidth = 12.dp.toPx(), cap = StrokeCap.Round, + ) + drawLine( + color = color, + start = Offset(startX, y), end = Offset(endX, y), + strokeWidth = 6.5.dp.toPx(), cap = StrokeCap.Round, + ) + } + + // Scrub indicator, drawn after every block so later bars can't paint over it + // (it used to live inside the loop, keyed off sorted.indexOf(block) — which is + // also O(n²) and resolves duplicate blocks to the same index). + if (isScrubbing && scrubBlockIndex in sorted.indices) { + val sx = xForMinute(scrubMinute, size.width) + drawLine( + color = Color.White.copy(alpha = 0.7f), + start = Offset(sx, 0f), + end = Offset(sx, size.height), + strokeWidth = 2.dp.toPx(), + ) + } } - // Horizontal segment per block: soft halo underlay + solid line. - sorted.forEach { block -> - val y = laneY(block.stageRaw) - val startX = x(block.startMinute) - val endX = x(block.startMinute + block.durationMinutes).coerceAtLeast(startX) - val color = stageColor(block.stageRaw) - drawLine( - color = color.copy(alpha = 0.16f), - start = Offset(startX, y), end = Offset(endX, y), - strokeWidth = 12.dp.toPx(), cap = StrokeCap.Round, - ) - drawLine( - color = color, - start = Offset(startX, y), end = Offset(endX, y), - strokeWidth = 6.5.dp.toPx(), cap = StrokeCap.Round, - ) + + // Lane labels — positioned using the same laneFraction math as the Canvas bars. + // Held back until the plot has been measured: at plotHeightPx == 0 every label + // resolves to the same y and they render stacked for a frame. + if (plotHeightPx > 0f) { + Box(Modifier.fillMaxSize()) { + lanes.forEach { stage -> + val yFrac = laneFrac[stage] ?: 0.62f + val labelY = yFrac * plotHeightPx + Text( + stage, + fontSize = 10.sp, fontWeight = FontWeight.SemiBold, + letterSpacing = 1.4.sp, color = stageColor(stage), + modifier = Modifier.offset { + IntOffset( + x = -LABEL_GUTTER_DP.dp.roundToPx(), + y = (labelY - LABEL_BASELINE_NUDGE_DP.dp.toPx()).roundToInt(), + ) + }, + ) + } + } + } + + // Stage readout pill. + if (isScrubbing && scrubBlockIndex in sorted.indices && plotWidthPx > 0f) { + val block = sorted[scrubBlockIndex] + val yFrac = laneFrac[block.stageRaw] ?: 0.62f + val pillY = yFrac * plotHeightPx + val scrubX = xForMinute(scrubMinute, plotWidthPx) + Box( + Modifier + .offset { + // Centre on the scrub line, then clamp to both edges so the pill + // stays on screen at the very start and end of the night. + val maxX = (plotWidthPx - pillWidth).coerceAtLeast(0f) + IntOffset( + x = (scrubX - pillWidth / 2f).coerceIn(0f, maxX).roundToInt(), + y = (pillY - PILL_OFFSET_ABOVE_LANE_DP.dp.toPx()).roundToInt(), + ) + } + .onSizeChanged { pillWidth = it.width }, + ) { + androidx.compose.foundation.layout.Box( + Modifier + .background(Color.Black.copy(alpha = 0.85f), RoundedCornerShape(8.dp)) + .padding(horizontal = 10.dp, vertical = 6.dp), + ) { + Text( + readoutText(block), + fontSize = 11.sp, + fontWeight = FontWeight.Medium, + color = Color.White, + ) + } + } } } } // Time ticks. Row( - Modifier.fillMaxWidth().padding(start = 64.dp, end = 16.dp), + Modifier.fillMaxWidth().padding(start = plotInsets.x, end = plotTrailing), horizontalArrangement = Arrangement.SpaceBetween, ) { ticks.forEach { Text(it, fontSize = 10.sp, color = PulseColors.textMuted) } @@ -790,3 +948,11 @@ internal fun parseChipsJson(json: String?): List { emptyList() } } + +// Hypnogram layout constants (iOS #131). The plot Box is inset by `plotInsets.x` (64dp) from the +// card edge; the lane labels sit in that gutter, so they offset back by LABEL_GUTTER_DP. +private const val LABEL_GUTTER_DP = 32f +/** Half the label's line height, so the text centres on its lane rather than hanging below it. */ +private const val LABEL_BASELINE_NUDGE_DP = 7f +/** Clearance between the scrubbed lane and the readout pill. */ +private const val PILL_OFFSET_ABOVE_LANE_DP = 30f diff --git a/app/src/main/java/com/pulseloop/ui/viewmodels/ViewModels.kt b/app/src/main/java/com/pulseloop/ui/viewmodels/ViewModels.kt index ab2d391..3e9e9b6 100644 --- a/app/src/main/java/com/pulseloop/ui/viewmodels/ViewModels.kt +++ b/app/src/main/java/com/pulseloop/ui/viewmodels/ViewModels.kt @@ -7,6 +7,7 @@ import com.pulseloop.data.dao.Bucket import com.pulseloop.data.entity.* import com.pulseloop.ring.* import com.pulseloop.coach.summaries.CoachSummaryKind +import com.pulseloop.service.DailyCalorieEstimator import com.pulseloop.service.HeartRateZones import com.pulseloop.service.SleepCoach import com.pulseloop.service.SleepInsights @@ -79,9 +80,20 @@ class TodayViewModel(db: PulseLoopDatabase, private val apiKeyStore: ApiKeyStore init { viewModelScope.launch { currentDayValues(todayStart, db.activityDailyDao()::byDayFlow).collect { activity -> + // iOS #98: the ring's own calorie figure wins when it reported one; otherwise show + // the on-device estimate (BMR accrued so far + net active). Reading `calories` + // straight off the row would show 0 all day for rings that report none — which is + // the whole reason the estimator exists. + val profile = db.userProfileDao().get()?.let { + DailyCalorieEstimator.Profile(it.sex, it.age, it.weightKg, it.heightCm) + } + val calories = activity?.let { row -> + profile?.let { DailyCalorieEstimator.effectiveCalories(row, it) } + ?: DailyCalorieEstimator.deviceReportedCalories(row) + } _state.update { it.copy( steps = activity?.steps, - calories = activity?.calories, + calories = calories, distanceMeters = activity?.distanceMeters, activeMinutes = activity?.activeMinutes, ) } @@ -619,7 +631,7 @@ class VitalsViewModel(private val db: PulseLoopDatabase, private val apiKeyStore bpDiaSeries = bpDia.map { VitalSample(it.timestamp, it.value) }, glucoseSeries = gluc.map { VitalSample(it.timestamp, it.value + glucoseOffset) }, peakHr = hr.maxOfOrNull { it.value }, - profile = apiKeyStore.physiologyProfile(userProfile?.age, userProfile?.sex), + profile = apiKeyStore.physiologyProfile(userProfile), // "Reference entered" ⇔ a non-zero calibration offset in Settings (0 = not set). hasBPReference = (apiKeyStore?.bpAdjustSystolic ?: 0) != 0 || (apiKeyStore?.bpAdjustDiastolic ?: 0) != 0, isGlucoseCalibrated = glucoseOffset != 0.0 || (apiKeyStore?.glucoseRefMgdl ?: 0.0) != 0.0, @@ -638,19 +650,29 @@ class VitalsViewModel(private val db: PulseLoopDatabase, private val apiKeyStore } /** - * Build the [UserPhysiologyProfile] from the stored age/sex plus the app-side physiology prefs + * Build the [UserPhysiologyProfile] from the stored profile row plus the app-side physiology prefs * (iOS #35). Nullable receiver so the (rare) no-store path still yields a sensible default profile. * The tri-state Settings values (`Boolean?`) collapse to the engine's non-null flags: null/false * both mean "no adjustment", only true tightens/relaxes a range. + * + * Takes the whole [UserProfileEntity] rather than just age/sex so the iOS #95 HR-zone fields travel + * with it — dropping them here left [VitalsThresholdEngine] with a null baseline and no way to see + * the user's chosen mode or custom boundaries, so every zone computation took the default branch. */ -private fun ApiKeyStore?.physiologyProfile(age: Int?, sex: String?): UserPhysiologyProfile = +private fun ApiKeyStore?.physiologyProfile(profile: UserProfileEntity?): UserPhysiologyProfile = UserPhysiologyProfile.fromProfile( - age, sex, + profile?.age, profile?.sex, athleteMode = this?.athleteMode ?: false, altitudeMeters = this?.altitudeMeters, usesBetaBlockers = this?.usesBetaBlockers == true, hasKnownLungCondition = this?.hasKnownLungCondition == true, preferredGlucoseUnit = this?.preferredGlucoseUnit ?: com.pulseloop.service.GlucoseUnit.MGDL, + hrZoneModeRaw = profile?.hrZoneModeRaw ?: "auto", + hrRestingBaseline = profile?.hrRestingBaseline, + hrCustomLowUpper = profile?.hrCustomLowUpper, + hrCustomAthleticUpper = profile?.hrCustomAthleticUpper, + hrCustomElevatedStart = profile?.hrCustomElevatedStart, + hrCustomHighStart = profile?.hrCustomHighStart, ) /** @@ -934,7 +956,7 @@ class VitalDetailViewModel( viewModelScope.launch { try { val userProfile = db.userProfileDao().get() - val physiology = apiKeyStore.physiologyProfile(userProfile?.age, userProfile?.sex) + val physiology = apiKeyStore.physiologyProfile(userProfile) engineThresholds(metric, physiology)?.let { engine -> _state.update { it.copy(thresholds = engine) } } @@ -1092,7 +1114,7 @@ class VitalDetailViewModel( // baseline-relative zones from the window's samples further below. val physiology = try { val userProfile = db.userProfileDao().get() - apiKeyStore.physiologyProfile(userProfile?.age, userProfile?.sex) + apiKeyStore.physiologyProfile(userProfile) } catch (_: Exception) { UserPhysiologyProfile.UNKNOWN } if (metric == "bp") { diff --git a/app/src/test/java/com/pulseloop/service/VitalsThresholdEngineTest.kt b/app/src/test/java/com/pulseloop/service/VitalsThresholdEngineTest.kt index 35cc7ed..ec3d06d 100644 --- a/app/src/test/java/com/pulseloop/service/VitalsThresholdEngineTest.kt +++ b/app/src/test/java/com/pulseloop/service/VitalsThresholdEngineTest.kt @@ -37,10 +37,10 @@ class VitalsThresholdEngineTest { @Test fun heartRateBoundaries() { - assertEquals("59 is below the 60 normal floor", ZoneSeverity.WATCH, severity(59.0, MetricKind.HEART_RATE, base)) - assertEquals(ZoneSeverity.NORMAL, severity(60.0, MetricKind.HEART_RATE, base)) - assertEquals(ZoneSeverity.NORMAL, severity(100.0, MetricKind.HEART_RATE, base)) - assertEquals("101 is above the 100 normal ceiling", ZoneSeverity.WATCH, severity(101.0, MetricKind.HEART_RATE, base)) + assertEquals("49 is below the 50 normal floor", ZoneSeverity.WATCH, severity(49.0, MetricKind.HEART_RATE, base)) + assertEquals(ZoneSeverity.NORMAL, severity(50.0, MetricKind.HEART_RATE, base)) + assertEquals(ZoneSeverity.NORMAL, severity(89.0, MetricKind.HEART_RATE, base)) + assertEquals("90 is at the elevated threshold", ZoneSeverity.WATCH, severity(90.0, MetricKind.HEART_RATE, base)) } @Test @@ -259,7 +259,7 @@ class VitalsThresholdEngineTest { @Test fun zoneThresholdsAreSortedBoundaries() { val thresholds = VitalsThresholdEngine.zoneThresholds(MetricKind.HEART_RATE, base) - assertEquals(listOf(60.0, 101.0, 120.0), thresholds) // the finite upper bounds, sorted + assertEquals(listOf(50.0, 90.0, 120.0), thresholds) // the finite upper bounds, sorted } // ── Android-specific additions ─────────────────────────────────────── diff --git a/app/src/test/java/com/pulseloop/strava/StravaAuthTest.kt b/app/src/test/java/com/pulseloop/strava/StravaAuthTest.kt new file mode 100644 index 0000000..32b2f5e --- /dev/null +++ b/app/src/test/java/com/pulseloop/strava/StravaAuthTest.kt @@ -0,0 +1,92 @@ +package com.pulseloop.strava + +import com.pulseloop.data.entity.ActivitySessionEntity +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Pure-logic Strava tests: scope parsing, duplicate parsing, sport mapping, activity naming. + * The HTTP and EncryptedSharedPreferences paths need instrumentation and aren't covered here. + */ +class StravaAuthTest { + + // ── Granted scope ─────────────────────────────────────────────────────────── + + @Test + fun `granted scope must contain activity write`() { + // Strava lets the user untick "Upload your activities" on the consent screen; without this + // check the connect appears to succeed and every later upload 401s with no explanation. + assertTrue(StravaAuth.grantedScopeIncludesWrite("read,activity:write")) + assertTrue(StravaAuth.grantedScopeIncludesWrite("activity:write, read")) + assertFalse(StravaAuth.grantedScopeIncludesWrite("read")) + assertFalse(StravaAuth.grantedScopeIncludesWrite("read,activity:read_all")) + assertFalse(StravaAuth.grantedScopeIncludesWrite(null)) + assertFalse(StravaAuth.grantedScopeIncludesWrite("")) + } + + @Test + fun `scope matching is exact, not a substring`() { + assertFalse(StravaAuth.grantedScopeIncludesWrite("activity:write_all")) + } + + // ── Duplicate detection ───────────────────────────────────────────────────── + + @Test + fun `duplicate errors yield the existing activity id`() { + assertEquals(123456L, StravaUploader.parseDuplicate("workout.tcx is a duplicate of activity 123456")) + assertEquals(987L, StravaUploader.parseDuplicate("duplicate of 987")) + assertEquals(42L, StravaUploader.parseDuplicate("DUPLICATE OF ACTIVITY 42")) + assertNull(StravaUploader.parseDuplicate("some other error")) + } + + // ── Sport mapping ─────────────────────────────────────────────────────────── + + @Test + fun `sport type never returns null`() { + assertEquals("Run", StravaSportMapping.toStravaType("run")) + assertEquals("Ride", StravaSportMapping.toStravaType("cycle")) + assertEquals("WeightTraining", StravaSportMapping.toStravaType("gym")) + assertEquals("Workout", StravaSportMapping.toStravaType("something-new")) + } + + @Test + fun `only run and cycle map losslessly through TCX`() { + assertFalse(StravaSportMapping.needsSportTypeFix("run")) + assertFalse(StravaSportMapping.needsSportTypeFix("cycle")) + assertTrue(StravaSportMapping.needsSportTypeFix("walk")) + assertTrue(StravaSportMapping.needsSportTypeFix("yoga")) + } + + // ── Activity naming ───────────────────────────────────────────────────────── + + @Test + fun `activity name is Strava-style time of day plus sport`() { + fun nameAt(hour: Int, type: String = "run"): String { + val cal = java.util.Calendar.getInstance().apply { + set(2024, 7, 7, hour, 30, 0); set(java.util.Calendar.MILLISECOND, 0) + } + return StravaUploader.activityName( + ActivitySessionEntity(id = "s", type = type, startedAt = cal.timeInMillis) + ) + } + + assertEquals("Morning Run", nameAt(7)) + assertEquals("Lunch Run", nameAt(12)) + assertEquals("Afternoon Run", nameAt(15)) + assertEquals("Evening Run", nameAt(19)) + assertEquals("Night Run", nameAt(23)) + assertEquals("Morning Ride", nameAt(9, "cycle")) + } + + @Test + fun `activity name is unbranded`() { + val cal = java.util.Calendar.getInstance().apply { set(2024, 7, 7, 9, 0, 0) } + val name = StravaUploader.activityName( + ActivitySessionEntity(id = "s", type = "run", startedAt = cal.timeInMillis) + ) + assertFalse(name.contains("PulseLoop", ignoreCase = true)) + } +} diff --git a/app/src/test/java/com/pulseloop/strava/StravaTCXBuilderTest.kt b/app/src/test/java/com/pulseloop/strava/StravaTCXBuilderTest.kt new file mode 100644 index 0000000..317200f --- /dev/null +++ b/app/src/test/java/com/pulseloop/strava/StravaTCXBuilderTest.kt @@ -0,0 +1,146 @@ +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 +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Mirrors iOS `StravaTCXBuilderTests.swift`. Android shipped #100 with no test for any of this — + * every assertion here failed against the first version. + */ +class StravaTCXBuilderTest { + + private val start = 1_723_000_000_000L // 2024-08-07T03:06:40Z + + private fun session( + type: String = "run", + endedAt: Long? = start + 30 * 60_000L, + pauseSeconds: Double = 0.0, + distance: Double? = 5000.0, + useGps: Boolean = true, + ) = ActivitySessionEntity( + id = "s1", type = type, statusRaw = "finished", startedAt = start, endedAt = endedAt, + totalPauseSeconds = pauseSeconds, calories = 320.0, distanceMeters = distance, + avgHeartRate = 141.4, maxHeartRate = 168.6, useGps = useGps, + ) + + private fun gps(offsetMs: Long, lat: Double = 51.5074, lon: Double = -0.1278, alt: Double? = 12.0) = + ActivityGpsPointEntity( + id = "g$offsetMs", sessionId = "s1", latitude = lat, longitude = lon, altitude = alt, + timestamp = start + offsetMs, accepted = true, + ) + + private fun hr(offsetMs: Long, bpm: Double) = MeasurementEntity( + id = "m$offsetMs", kindRaw = "HEART_RATE", value = bpm, unit = "bpm", + timestamp = start + offsetMs, sourceRaw = "ring", + ) + + @Test + fun `Id is an ISO-8601 dateTime, not epoch seconds`() { + // TCX types Activity/Id as xsd:dateTime. Emitting `startedAt / 1000` produced + // 1723000000, which is not a valid dateTime. + val tcx = StravaTCXBuilder.build(session(), listOf(gps(0), gps(60_000)), listOf(hr(0, 140.0)))!! + assertTrue(tcx, tcx.contains("2024-08-07T03:06:40Z")) + assertFalse(tcx.contains("1723000000")) + } + + @Test + fun `TriggerMethod is present`() { + // Required by the TCX ActivityLap schema; omitting it makes the document invalid. + val tcx = StravaTCXBuilder.build(session(), listOf(gps(0), gps(60_000)), emptyList())!! + assertTrue(tcx.contains("Manual")) + } + + @Test + fun `TotalTimeSeconds excludes paused time`() { + val tcx = StravaTCXBuilder.build( + session(pauseSeconds = 300.0), listOf(gps(0), gps(60_000)), emptyList(), + )!! + // 30 min elapsed − 5 min paused = 1500 s of moving time. + assertTrue(tcx, tcx.contains("1500.0")) + } + + @Test + fun `returns null when there is nothing to emit`() { + // No accepted GPS and no HR — an empty is schema-invalid, so the caller must fall + // back to a manual activity instead of uploading garbage. + assertNull(StravaTCXBuilder.build(session(), emptyList(), emptyList())) + } + + @Test + fun `a single GPS fix is not a route and falls back to HR trackpoints`() { + val tcx = StravaTCXBuilder.build(session(), listOf(gps(0)), listOf(hr(0, 132.0)))!! + assertFalse("one fix is not a route", tcx.contains("")) + assertTrue(tcx.contains("132")) + } + + @Test + fun `HR merges into the most recent fix within the staleness window`() { + val tcx = StravaTCXBuilder.build( + session(), + listOf(gps(0), gps(30_000), gps(120_000)), + listOf(hr(0, 120.0), hr(25_000, 150.0)), + )!! + assertTrue(tcx.contains("120")) + assertTrue(tcx.contains("150")) + // The 120 s fix is >60 s past the last sample, so it carries no HR. + val lastPoint = tcx.substringAfterLast("") + assertFalse("stale HR must not be smeared forward", lastPoint.contains("HeartRateBpm")) + } + + @Test + fun `trackpoints inside a pause are dropped`() { + val pauses = listOf(StravaTCXBuilder.PauseInterval(start + 20_000, start + 40_000)) + val tcx = StravaTCXBuilder.build( + session(), listOf(gps(0), gps(30_000), gps(60_000)), emptyList(), pauses, + )!! + assertEquals(2, Regex("").findAll(tcx).count()) + } + + @Test + fun `pause intervals pair paused with the next resumed`() { + val events = listOf( + ActivityEventEntity(id = "1", sessionId = "s1", kind = "paused", timestamp = start + 10_000), + ActivityEventEntity(id = "2", sessionId = "s1", kind = "resumed", timestamp = start + 40_000), + ActivityEventEntity(id = "3", sessionId = "s1", kind = "paused", timestamp = start + 80_000), + ) + val intervals = StravaTCXBuilder.pauseIntervals(events, endedAt = start + 100_000) + + assertEquals(2, intervals.size) + assertEquals(start + 10_000, intervals[0].start) + assertEquals(start + 40_000, intervals[0].end) + // The unpaired trailing pause closes at endedAt. + assertEquals(start + 100_000, intervals[1].end) + } + + @Test + fun `sport attribute uses the three TCX-legal values`() { + fun sportOf(type: String) = StravaTCXBuilder + .build(session(type = type), listOf(gps(0), gps(60_000)), emptyList())!! + .substringAfter("51.507400")) + assertTrue(tcx.contains("5000.0")) + } + + @Test + fun `heart rate summary values are rounded, not truncated`() { + val tcx = StravaTCXBuilder.build(session(), listOf(gps(0), gps(60_000)), emptyList())!! + assertTrue(tcx.contains("141")) + assertTrue(tcx.contains("169")) + } +} diff --git a/docs/faceless-band-hardware-survey.md b/docs/faceless-band-hardware-survey.md new file mode 100644 index 0000000..e99454e --- /dev/null +++ b/docs/faceless-band-hardware-survey.md @@ -0,0 +1,142 @@ +# Faceless (screenless) health-band ODM survey — white-label sourcing + +**Question this answers:** beyond the smart *rings* already in PulseLoop's catalog +(Colmi/Yawell R02–R12, H59, jring), which **displayless / screenless wristbands** have +sensors as good as — or better than — the **Colmi R10** and **Rogbid Loop Air**, **from +manufacturers that offer white-label / OEM / ODM** (rebrandable — custom logo, custom +app/SDK, sold B2B with an MOQ)? And which could PulseLoop's app actually talk to? + +**Scope:** screenless wrist bands/straps only (Whoop/Helio-Strap form factor); no screened +watches, no rings. **Hard filter: white-label-capable suppliers.** Consumer-only brands +(Whoop, Fitbit, Amazfit, Hume) appear only as sensor benchmarks, not sourcing options. + +> **Sourcing caveat.** The `firecrawl` CLI was out of API credits during this research, so +> data came from web search + vendor/ODM pages (Alibaba/Made-in-China/company sites) + the +> openFDA 510(k) and FCC-ID databases + the Gadgetbridge device registry. Cheap-ODM spec +> sheets are thin and MOQ/pricing is usually quote-only; unknowns are marked *undisclosed*. +> Every "medical-grade / ECG / blood-pressure / glucose" claim is flagged where it is +> marketing rather than a cleared or validated capability. + +--- + +## 1. The headline finding: it's one shared reference design + +Almost every sub-$70 "screenless ECG band" is the **same Shenzhen reference platform**, +resold under many names: + +> **Jieli JL7013A/JL7073A8** BLE SoC (main control) · **Goodix GH3228T** PPG + single-lead +> **ECG** AFE ("500 Hz") · **PD2325** photodiode · **Minghao DA267** accelerometer. + +Goodway's **E900**, Shenzhen **Staranb**'s ECG band, the **Valdus Vitro / "G band"** +rebadges, and — on the silicon evidence — the **Rogbid Loop** (GH3228, 500 Hz) are all this +platform. So a white-label buyer isn't choosing a product; they're **choosing which ODM to +brand the reference design through**, and the differentiator is **MOQ, price, and whether +you get an SDK/API or only a rebranded app.** + +--- + +## 2. Grading rubric: the PPG/ECG chipset, not the metric list + +Every band advertises "HR · SpO₂ · HRV · sleep." What separates R10-class from genuinely +good is the **analog front-end (AFE)**: + +| AFE / sensor | Vendor | Class | What it buys | Seen in | +|---|---|---|---|---| +| **Vcare VC30F / VC31B** | Vcare | Budget single-channel PPG | HR, basic SpO₂, *algorithmic* HRV | **Colmi R10 (baseline)**, Staranb STH59, most cheap bands | +| **Goodix GH3026** | Goodix | Multichannel PPG (2 AFE, ≤16 ch) | better motion rejection, cleaner SpO₂/HRV | iSmarch, XBAND (benchmark) | +| **Goodix GH3220 / GH3228 / GH3228T** | Goodix | **PPG + single-lead ECG** (500 Hz) | real ECG AFE + PWTT (basis of PPG "BP") | Goodway E900, Staranb ECG, Rogbid Loop, J-Style V8/J1790 | +| **TI AFE4900/4950 · ADI/Maxim MAX86176** | TI / ADI | Premium PPG+ECG | multi-LED/PD, synced ECG | premium/clinical, J-Style 2208/2318 (Maxim PPG) | +| **Proprietary multi-PD arrays** | Whoop/Amazfit/Fitbit/Hume | Premium | 4–5 photodiodes, multi-wavelength | consumer benchmarks only | + +**Bar-setting:** the Colmi R10's **VC30F is the floor** (single-channel). A band is +*meaningfully better sensors than the R10* if it's on **GH3026** (multichannel PPG) or +**GH322x/GH3228T** (adds real single-lead ECG), or a **≥4-PD array**. Caveats that hold +across the whole category: GH3228-class "ECG" through one side electrode is **rhythm-strip +grade, not diagnostic**; "blood pressure" and "glucose" are **uncalibrated PPG/PWTT +estimates**; **none of these ODMs is FDA-cleared** (openFDA 510(k) returns no matches for +J-Style/Youhong/Yawell; ISO 13485 is a quality-system cert, not a device clearance). + +**Baseline — Colmi R10:** Vcare **VC30F** PPG · **STK8321** accel · Realtek **RTL8762E** BLE +SoC → HR, SpO₂, algorithmic HRV/stress, sleep. No real ECG/BP; R10 skin-temp is doubtful +(protocol reports temp only for R05/R09). QRing app, Oudmon/**Jxr35** 16-byte protocol — +the one **PulseLoop already speaks**. + +--- + +## 3. White-label ODM comparison (R10-class or better) + +⭐ = standout for its column. "Real ECG?" = physical electrode + a genuine ECG AFE. +"Protocol" = does it fit PulseLoop's existing Jxr35/Yawell stack, or need new work? + +| Supplier | Product | Sensors / AFE | Real ECG? | MOQ | ~Price | White-label: logo · app · **SDK** | App / BLE protocol | vs R10 | +|---|---|---|---|---|---|---|---|---| +| **Shenzhen Yawell Intelligent** (Colmi's own ODM) | Y25 / Y91 / S8 OEM bands; H59 | VC30-class PPG; HR/SpO₂/HRV/sleep; RTL8762 | No | OEM | wholesale | logo ✅ · app ✅ · SDK via Yawell | **QRing/QWatch Pro = Jxr35 → PulseLoop already speaks it** ⭐ | ~R10-equal | +| **Shenzhen Staranb** | STH59 no-screen band | RTL8762ESF; VC30-class PPG HR/SpO₂/BP*(flag)*; no ECG | No | **1 pc (sample)** ⭐ | **$9.80–10.85** ⭐ | logo ✅ · app ✅ · **SDK+API ✅** | app "**Qwatch Pro**" (same Jxr35 family → likely already speaks) ⭐ | ~R10-equal | +| **Shenzhen Staranb** | AI-Health ECG band | **Goodix GH3228T** PPG+ECG; HRV, body temp | **Yes** (rhythm) | 1 pc (sample) | ~$15–25 est. | logo ✅ · app ✅ · **SDK+API ✅** ⭐ | own SDK (new integration) | **Above R10** (ECG) | +| **J-Style / Jointcorp** (Youhong; ISO 13485, CE/FCC) | JCVital **V8** / **J1790** ECG band; 2208A | **GH322x** PPG+single-lead ECG (V8/J1790); 2208A basic PPG+temp | **Yes** (V8/J1790) | **1,000** | quote | logo ✅ · pkg ✅ · **full iOS/Android SDK+API ✅** (best-documented) ⭐ | own SDK/app + cloud | **Above R10** (ECG) ⭐ overall | +| **Goodway Technologies** | E900 (= Valdus Vitro / "G band") | JL7013A; **GH3228T** + PD2325 PD; DA267 accel; HR/SpO₂/HRV/ECG/temp | **Yes** (rhythm) | undisclosed | undisclosed | logo ✅ · pkg ✅ · app ✅ · **SDK not advertised — flag** | "G band" app (protocol undisclosed) | **Above R10** (ECG) | +| **iSmarch** (Shenzhen) | screenless / hybrid variants | **GH3026-class** multichannel 500 Hz PPG + **ECG** + **EDA/GSR** + accel | **Yes** (option) | **2,000–3,000** | EXW quote | logo ✅ · **hardware + protocol/SDK docs ✅** (buyer builds own app) | own protocol/SDK | **Above R10** ⭐ best sensors (multichannel PPG + EDA) | +| **Rogbid** (Shenzhen Ruigebaike) | Loop / Loop Air | Loop: **GH3228** PPG+ECG; Loop Air: JL7073A8 + PPG/ECG/**NTC temp** + **GPS** | **Yes** (Loop); Loop Air soft | white-label per you† | retail $60–70 | white-label per you† · **no *public* OEM/SDK program — direct contact only** | Rogbid app (protocol undisclosed) | **Above R10** (Loop ECG; Loop Air GPS+temp) | + +† You noted Rogbid offers white-labelling. Publicly they present as a vertically-integrated +D2C brand (Shenzhen Ruigebaike) with **no listed OEM/ODM or SDK program**, so terms would be +direct-contact-only. Note their Loop is the **same GH3228 reference design** you can also +brand through **Goodway or Staranb** — with a published SDK — if Rogbid won't hand over one. + +**Consumer benchmarks (NOT white-label — for sensor comparison only):** Whoop 5.0/MG +(multi-wavelength 4-PD PPG + ECG, subscription) · Amazfit Helio Strap (BioTracker 6.0, 5-PD, +no sub, ~$99) · Fitbit Air (Google AFE, AFib, $99) · Hume Band 2.0 (5-LED/4-PD) · **XBAND / +Codex** (Goodix **GH3026** + nRF52840 in a strap — proves the good AFE exists in this form, +but no rebrand program, ~$279). + +--- + +## 4. What this means for PulseLoop + +There's a real tradeoff between **protocol fit** and **sensor quality**: + +- **Cheapest path, sensors = R10, protocol already done:** **Yawell OEM bands** and + **Staranb STH59**. Both run the **QRing/QWatch Pro (Jxr35) protocol PulseLoop already + implements** (Staranb's app is literally "Qwatch Pro"; the H59 already in `WearableModel.kt` + as "H59 Ring" is really a screenless *band* whose Gadgetbridge coordinator extends + `AbstractYawellRingCoordinator`). Staranb even white-labels at **1-pc MOQ + SDK for ~$10**. + → **Highest-confidence, lowest-effort expansion.** Confirm with a BLE sniff, then add the + band to the catalog; likely little-to-no new protocol code. +- **Better sensors (real ECG / multichannel), needs new integration but you get an SDK:** + **J-Style/Jointcorp** (GH322x ECG + full iOS/Android SDK+API, MOQ 1,000) is the strongest + overall when you want cardiac sensors *and* your own app/data pipeline. **Staranb's + GH3228T band** is the cheapest way to the same ECG AFE with an SDK. **iSmarch** goes + furthest on sensors (GH3026 multichannel PPG + ECG + **EDA/GSR**) but at MOQ 2–3k. + → Each hands over an SDK/API, so this is integration work, not blind reverse-engineering. +- **Rogbid** Loop/Loop Air are attractive hardware (real ECG; GPS+NTC temp) and you say + they'll white-label — but with no public SDK, sourcing the **same GH3228 design via + Goodway/Staranb** (which publish SDKs) is the safer route for an app-first product. + +**Recommended shortlist for a white-label buyer:** +1. **Staranb STH59** — R10-class sensors, ~$10, 1-pc MOQ, SDK, **and the protocol PulseLoop + already speaks.** Fastest to ship. +2. **J-Style / Jointcorp V8 / J1790** — real single-lead ECG + the best-documented + iOS/Android SDK+API. Best if ECG and your own app/cloud matter. +3. **Staranb GH3228T ECG band** — cheapest genuine-ECG-AFE with SDK + low MOQ. +4. **iSmarch** — if you want the best sensors (multichannel GH3026 PPG + ECG + EDA) and can + meet a 2–3k MOQ. +5. **Rogbid Loop / Loop Air** — if their white-label terms + an SDK check out; otherwise + brand the underlying GH3228 design through Goodway/Staranb. + +**Don't ship the marketing.** Across every ODM here, treat cuffless "blood pressure," +non-invasive "glucose," and sub-$70 "ECG" as unvalidated (rhythm-grade at best). The only +faceless band with a genuine clinical clearance for any of it is **Aktiia/Hilo** (cuffless +BP, FDA 510(k)) — and it's consumer-only, not white-label, and still needs cuff calibration. + +--- + +### Sources +Reference design / ODMs: goodwaytechs.com (E900) · staranb.en.made-in-china.com (STH59 + +ECG band) · jointcorp.com (JCVital V8, J1790, /sdk-api) · valdusvitro.com · ismarch.com · +Rogbid: store.rogbid.com + tracxn (Shenzhen Ruigebaike) + gizmochina (Loop/Loop Air +chipsets). Protocol fit: gadgetbridge.org/…/yawell · Gadgetbridge PR #5039 (H59) · Play +Store `com.qcwireless.qcwatch` · fccid.io/2AOM3-Y91 · /2AOM3-S8. Chipsets: goodix.com +(GH3026/3220/3228T) · ti.com (AFE4900/4950) · analog.com (MAX86176) · espruino R10 teardown. +Regulatory: openFDA 510(k) (no matches for J-Style/Youhong/Yawell). Benchmarks: whoop.com + +TechInsights teardown · us.amazfit.com · blog.google (Fitbit Air) · humehealth.com · +gearpatrol (XBAND/Codex) · Aktiia/Hilo FDA via biospace/medtechdive. diff --git a/docs/ios-sync.md b/docs/ios-sync.md index 1bb7d71..d5713b7 100644 --- a/docs/ios-sync.md +++ b/docs/ios-sync.md @@ -24,9 +24,10 @@ intentional platform differences listed at the bottom. |---|---| | **Canonical iOS repo** | `github.com/saksham2001/PulseLoopiOS` (always `main`) | | **Fork baseline (iOS)** | `600c7a8` — Merge PR #6, 2026-06-20 | -| **Last triaged iOS commit** | `0d1b965` — seed: month of demo workouts + 30-day vitals series, 2026-07-18 | -| **Last triage date** | 2026-07-18 | -| **Range covered** | 48 commits / 9 first-parent items since `b3697c0` (2026-07-12), plus 1 direct commit (`0d1b965`) | +| **Last triaged iOS commit** | `88c0f6b` — Merge PR #131 (sleep hypnogram alignment + scrubber), 2026-08-08 | +| **Last triage date** | 2026-08-08 | +| **Last port date** | 2026-08-08 — PR #45 (ios_sync_2026-08-08, 5 plan commits + 2 CR remediation commits = 7 total) | +| **Range covered** | 11 first-parent items since `0d1b965` (2026-07-18): PRs #73, #94–#100, #130, #131 + 1 direct commit (`160c775`) → **10 ported, #130 backed out** | --- @@ -97,10 +98,39 @@ Ordered roughly by value-for-effort. Status: ☐ open · ☑ done. series", dev-only `PulseLoop/Persistence/SeedData.swift` change (denser demo data for iOS's own seeded-data mode). **SKIP** — no portable behavior, Android has its own independent seed data. -## Port priority — open items (as of 2026-07-17) +--- NEW (2026-08-08 triage, 11 items since `0d1b965`) --- -Single source of truth for what to port next, ranked value-for-effort. Small correctness/feature -wins first, XL ring rebuilds last on their own branches, blocked/deferred at the bottom. +| # | iOS PR | Merged | Title | Verdict | Effort | Android commit | +|---|--------|--------|-------|---------|--------|----------------| +| ☑ | [#73](https://github.com/saksham2001/PulseLoopiOS/pull/73) `7a30014` | ~07-20 | Privacy & Data Reset (Unpair Ring / Reset App Data / Unpair+Reset) | **PORT** | S–M | `802789d` | +| ☑ | [#94](https://github.com/saksham2001/PulseLoopiOS/pull/94) `459f7f1` | ~07-21 | Background syncs + `StaleDataPolicy` + data-gated coach notifications | **ADAPT** | M | `0ca53a1` + `c4aab74` (CR fix: wire STALE_DATA_WINDOW_MS) | +| ☑ | [#95](https://github.com/saksham2001/PulseLoopiOS/pull/95) `dae95ab` | ~07-22 | HR zone colors/thresholds (evidence-based defaults + Standard/Auto/Custom modes + resting-HR baseline learning) | **PORT** | M–L | `0ca53a1` | +| ☑ | [#97](https://github.com/saksham2001/PulseLoopiOS/pull/97) `cb8e1cd` | ~07-23 | LittleMeatball R10M YCBT support + 9 shared YCBT bugfixes | **ALREADY-HAVE** | — | iOS PR is itself a port of PulseLoopAndroid#31 | +| ☑ | [#96](https://github.com/saksham2001/PulseLoopiOS/pull/96) `c0def0f` | ~07-24 | Calorie + macro nutrition tracking (meal logging, barcode scan, OFF search, AI photo analysis, coach `log_meal` tool, intake goals, provenance tags) | **ADAPT (subset — manual meal logging + goals only; no OFF search, barcode, AI photo or coach `log_meal`)** | XL | `4084671` + `c4aab74` (CR fix: null-goal guard, dead button wired) | +| ☑ | [#99](https://github.com/saksham2001/PulseLoopiOS/pull/99) `f06be51` | ~07-25 | Full-data JSON export/import (all models → single JSON file, atomic wipe-and-restore on import) | **PORT** | M | `802789d` + `c4aab74` (CR fix: atomic transaction, wearableLogs roundtrip, BuildConfig appVersion) | +| ☑ | [#100](https://github.com/saksham2001/PulseLoopiOS/pull/100) `4947628` | ~07-26 | Strava OAuth connect + TCX upload (GPS-HR merge, auto-dedup, token refresh) + shareable PNG stat cards | **ADAPT** | L | `4ce34dc` + `c4aab74` (CR fix: mobile endpoint, intent-filter, redirect handler, pollUntilDone, BuildConfig secrets, shared OkHttpClient) | +| ☑ | — `160c775` | ~07-26 | Set version to 2.5.0 + read About version from bundle | **ALREADY-HAVE** | — | `68c9788` (versionName → 2.5.0 to match iOS MARKETING_VERSION) | +| ☑ | [#98](https://github.com/saksham2001/PulseLoopiOS/pull/98) `ac01555` | ~07-27 | On-device daily calorie estimation (Mifflin-St Jeor BMR + Keytel/MET active energy, HR-gated) for rings that don't report calories | **PORT** | M | `0ca53a1` | +| ☐ | [#130](https://github.com/saksham2001/PulseLoopiOS/pull/130) `cf5c0f4` | ~08-04 | RWfit ring family (dual 0x7E/0xAB protocol, full metric set, service-UUID recognition) | **ADAPT** | L–XL | **BACKED OUT of PR #45** — see "RWfit (#130) — backed out" below. Work preserved on `feat/rwfit-ring-family`; redo against `decompiled-rwfit-official/`. | +| ☑ | [#131](https://github.com/saksham2001/PulseLoopiOS/pull/131) `88c0f6b` | ~08-08 | Sleep hypnogram label alignment + press-and-hold stage scrubber (+ sync spinner rewrite, iOS-only) | **ADAPT** | S–M | `802789d` | + +## Port priority — open items (as of 2026-08-08) + +> **STATUS: 10 of 11 ported; #130 backed out.** PR #45 (`ios_sync_2026-08-08`) ported all +> 11 items across Tiers 1–3 in 5 plan commits, then a cross-platform review against the iOS +> sources found #130 (RWfit) to be a fabricated protocol rather than a port — it was backed +> out so the other 10 items can land. Version bumped to 2.5.0 (`68c9788`) to match iOS +> MARKETING_VERSION. + +> **▶ RESUME HERE (next session):** Two open threads, in order: +> 1. **PR #45 review remediation** — the 2026-08-09 review found parity bugs in #95, #98, +> #99, #100 and a regression in #94. See "Session notes — 2026-08-09 cross-platform +> review" below. +> 2. **#130 RWfit redo** — on `feat/rwfit-ring-family`, rebuilt from +> `decompiled-rwfit-official/` (see the backed-out section below for what was wrong), +> then recombined. +> +> Next triage after those: `git -C log --first-parent --oneline 88c0f6b..main`. > **▶ RESUME HERE (next session):** Tier 1 and Tier 2 both fully clear — **#65 is DONE**, re-triaged > into #65a–f, all landed 2026-07-17/18: **#65a** persistence (`daed897`), **#65b** usage tracking @@ -1238,6 +1268,181 @@ main-thread access from a background worker, and Room calls on the right dispatc --- +## Session notes — 2026-08-08 CR remediation + +Code review of PR #45 (`ios_sync_2026-08-08`) against the official Strava Android OAuth +documentation + runtime-correctness checks. 14 findings, 11 fixed: + +### Showstoppers fixed +- **Strava: wrong auth endpoint** → switched from `/oauth/authorize` to `/oauth/mobile/authorize` + (per Strava Android docs — mobile and web endpoints are explicitly different) +- **Strava: no OAuth redirect capture** → added intent-filter for `pulseloop://` in + AndroidManifest, `onNewIntent()` handler + cold-start handler in `MainActivity` calling + `exchangeCode()`, LaunchedEffect polling in `StravaSettingsScreen` to pick up stored tokens +- **Strava: hardcoded secrets burned** → moved `CLIENT_ID`/`CLIENT_SECRET` to `local.properties` + → `BuildConfig` (gitignored, matching iOS `StravaSecrets.plist` pattern), added `isConfigured` gate +- **Data import not atomic** → wrapped entire nuke+insert flow in single `beginTransaction()/ + setTransactionSuccessful()/endTransaction()` block +- **`pollUntilDone` no-op** → implemented with retry loop polling `GET /api/v3/uploads/{id}` + +### High issues fixed +- **Stacked AlertDialogs** (PrivacySettingsScreen) → 5 independent `if` blocks converted to + single `when` chain, preventing simultaneous dialog stacking +- **WearableLogs roundtrip data loss** → added `categoryRaw`/`levelRaw` to `WearableLogDTO`; + import uses DTO fields instead of hardcoded "CONNECTION"/"INFO" +- **`appVersion` hardcoded** → changed from `"android-unknown"` to `BuildConfig.VERSION_NAME` +- **Nutrition toggle creates fresh entity** → added `goalLoaded` guard; Switch disabled until + goal loads from DB, `copy()` preserves existing fields for non-null case +- **Dead "Open Nutrition Log" button** → wired `onNavigateToNutrition` callback through + `PulseLoopApp` routing + +### Medium issues fixed +- **Dead imports** (`SleepScreen.kt`) → removed `SimpleDateFormat`, `Date`, `Locale`, `abs` +- **`STALE_DATA_WINDOW_MS` unused** → wired into `ensureFreshData()`: when data is older than + 1h, force connect+sync even if app is foregrounded (the `isAppForeground()` early-return is + now `!dataIsStale && isAppForeground()`) +- **Redundant `deviceDao().clear()`** in `performUnpairAndReset` → removed (already covered + by `nukeAllTables()`) +- **New `OkHttpClient` per call** → shared singleton with 30s timeouts in `StravaAuth` + +### Not fixed (deferred) +- **RWfitDecoder checksum/CRC validation** — needs protocol-level verification against vendor + app captures; not fixable without hardware or reference data +- **Nutrition toggle comment in review (#9)** — the review claimed `copy()` creates fresh entity + with all defaults, but Kotlin `copy()` preserves existing fields. The null-goal guard added + above is sufficient. + +### Strava integration — architecture validation + +Validated against official Strava docs at developers.strava.com/docs/authentication: + +| Element | Android spec | Before fix | After fix | +|---------|-------------|------------|-----------| +| Auth endpoint | `/oauth/mobile/authorize` | `/oauth/authorize` | `/oauth/mobile/authorize` | +| Redirect URI | `pulseloop://` + intent-filter | `pulseloop://` no filter | `pulseloop://` + intent-filter | +| Token exchange | `POST /oauth/token` | `POST /oauth/token` | same (was correct) | +| Credentials | Per-developer `local.properties` | Hardcoded in source | `BuildConfig` from `local.properties` | +| State param | Optional, echoed back | Generated, never validated | Generated + validated (`b073dad`) | +| OkHttpClient | Shared singleton | New instance per call | Shared singleton | + +--- + +## Session notes — 2026-08-09 cross-platform review + +A second review pass on PR #45, this time diffing each ported item against the iOS source it came +from and validating Strava against the official Android OAuth docs. Everything below is fixed +(`8df67b1`, `8f81c40`); #130 was backed out separately. + +### The recurring failure mode: wired-up-but-inert + +Three items shipped as code with **no caller**, so they passed review and tests while doing nothing: + +- **#98** `effectiveCalories` / `effectiveActiveCalories` — zero callers. The estimator computed and + stored `estimatedActiveCalories` every sync and no screen ever read it. Now read by + `TodayViewModel`. +- **#95** `RestingHRBaselineService` — zero callers, so `hrRestingBaseline` stayed null forever and + the default `"auto"` HR-zone mode always took the no-baseline branch. The net effect of the port + was to move everyone's "normal" band from 60–100 to 50–90 *without* the personalisation that + justified moving it. Now called on sync completion. +- **#96** `food_products` / `FoodProductDao` / `CachedFoodProductEntity` — zero callers outside + their own definitions. No Open Food Facts client and no barcode scanner were ported, so the table + can never be populated. Left in place (the archive now round-trips it) but the ledger row is + corrected: #96 is **ADAPT (subset)**, not PORT. + +Worth adding to the port checklist: *grep for a caller before marking an item done.* + +### Correctness bugs + +| Item | Bug | +|---|---| +| #99 | `importFile` opened a raw framework transaction on `Dispatchers.Default`, then called suspend Room DAOs that hop to Room's query dispatcher — deadlocking on the write connection the suspended thread held. `db.withTransaction { }` fixes it, and is also what fires the invalidation tracker. | +| #98 | `deviceReportedCalories` was **inverted**: it returned the row's calories only for `source == "ring_history"` (the one case iOS excludes) and ignored real device values everywhere else. | +| #98 | Missing the workout term, the overlap accounting and the residual-steps term — so HR-covered minutes were paid for twice, and live-only days scored zero. | +| #98 | `recompute` inserted an `activity_daily` row when none existed, fabricating up to 7 phantom zero-step days per sync. | +| #94 | `STALE_DATA_WINDOW_MS` (1h) was evaluated only after the 3h early-return, so `dataIsStale` was always true — silently deleting the foreground guard and letting the coach worker open a second GATT client while the app held the link. | +| #99 | `wearableLogs` packed `"CATEGORY/LEVEL: message"` into `event` and the importer assigned it back to `message`, re-prefixing on every round-trip. | +| #99/#96 | `ActivityDailyDTO.estimatedActiveCalories` declared but never populated; `UserGoalDTO` dropped all five intake fields; `meal_entries`/`food_products` wiped on import but never restored. | +| #100 | OAuth state held in memory across a browser round-trip that can outlive the process; no `error=access_denied` handling; no granted-scope check; refresh "single-flight" that still burned the rotated token; no deauthorize on disconnect; `Reset App Data` left the Strava tokens on device. | +| #100 | TCX `` was epoch seconds (schema wants `xsd:dateTime`), no ``, wall-clock `TotalTimeSeconds`, and an empty `` uploaded for workouts with no GPS and no HR instead of the manual-activity fallback. | +| #100 | `uploadAuto` had one caller (the manual button) while the UI claimed automatic upload, and would back-fill 20 historical workouts to a public feed on first connect. | +| #131 | Scrub indicator drawn inside the per-block loop (later bars painted over it) via an O(n²) `indexOf`; pill unclamped at the right edge; labels stacked for one frame. | + +### Strava — validated against developers.strava.com/docs/authentication + +| Element | Android spec | Status | +|---------|-------------|--------| +| Auth endpoint | `GET /oauth/mobile/authorize` via implicit `ACTION_VIEW` | correct | +| Redirect URI | custom scheme + intent-filter, `localhost` whitelisted | correct | +| Token exchange / refresh | `POST /oauth/token` | correct | +| `state` | optional, always echoed back | **was generated then dropped on process death** → persisted | +| `error=access_denied` | returned on decline | **was ignored** → handled | +| Granted `scope` | echoed; `activity:write` is optional for the user | **was unchecked** → verified | +| Refresh-token rotation | every exchange rotates it | **concurrent refreshes burned it** → re-read under lock | +| Deauthorize | `POST /oauth/deauthorize` (docs now prefer `/oauth/revoke`) | **was never called** → called on disconnect | + +### Test coverage + +iOS shipped ~1,400 lines of tests with #99/#98/#100; the Android port added none. This pass adds +`StravaTCXBuilderTest` (12) and `StravaAuthTest` (6) — every assertion in the TCX file fails against +the pre-fix builder. Suite: 794 → 812. + +### Still open + +- **#94's actual feature** is `CoachNotificationDataTrigger` (run the due slot when a sync + completes, recovering a slot skipped for stale data). Not ported — it's an event-bus subscriber, + not the window constant that was mistaken for it. +- **#96 subset**: no OFF search, no barcode scan, no AI photo analysis, no coach `log_meal` tool. +- **Pause intervals**: `activity_events` is never written on Android, so TCX can't drop paused + trackpoints yet. `totalPauseSeconds` is honoured. + +--- + +## RWfit (#130) — backed out of PR #45, 2026-08-09 + +**The Android port did not come from the vendor app.** Every wire-level constant was invented, +so none of it could ever have talked to a real ring. iOS `RWfitProtocol.swift` cites its source +file-by-file (`com.rw.revivalfit`, paths relative to `rwfit-official/sources/`); the same +decompile is at this repo's root as **`decompiled-rwfit-official/`** — use it, not iOS and not +guesswork (root `AGENTS.md` rule). + +| | iOS / vendor app | Backed-out Android port | +|---|---|---| +| Service | `0000a00a-…` | `0000a00a-…` ✅ | +| **Write characteristic** | `0000b002-…` (`y5/a.java f19995b`) | `0000a002-…` ❌ | +| **Notify characteristic** | `0000b003-…` (`f19996c`) | `0000a003-…` ❌ | +| Legacy `0x7E` frame | `7E 01 cmd flags dataLen serHi serLo xor ` (`x5/d.java`) | `7E len cmd xor` ❌ | +| deviceInfo / battery / setTime | `0x00` / `0x01` / `0x21` | `0x01` / `0x02` / `0x03` ❌ | +| History | `0xA0` manifest + `0xA1`–`0xA7` per-stream | invented `0x10` / `0x11` ❌ | +| Unbind | `0x44` (`h0.java:319`) | `0xFF` ❌ | +| `0xFE`/`0xFF` ACK handshake | mandatory | absent ❌ | +| JieLi addressing | `{cmd, key, keyFlag}` triples (`y5/c.java`) | always `key=0, keyFlag=0` ❌ | +| Framing selection | post-connect, from sibling `AE00` / Telink OTA / PixArt `FF00` services (`r5/b.java:703-727`) | hardcoded legacy; `useAbProtocol` never set true ❌ | +| Recognition | `A00A` advertisement + manufacturer prefixes `d6050200`/`d6054154`/`d6060200`; **no name matching, on purpose** | `name.startsWith("RW")` ❌ | +| Capabilities | baseline set + `bitmapGatedCapabilities` (no manual-measure on a legacy link) | all 13 granted unconditionally ❌ | + +Logic bugs found in the same pass, independent of the vendor mismatch: + +- `decodeStress`: `p[0].toInt() and 0xFF.coerceIn(0, 100)` — precedence makes this `p[0] and 0x64`, + a garbage bitmask rather than a clamp. +- `decodeSleep`: computes `totalMin`/`deep`, discards both, returns `stages = emptyList()`. +- `decodeRaw`'s `else` branch emits a bogus `Status` event for every unknown frame. +- `RWfitSyncEngine`: no time sync, never calls `requestHistory()`, empty `handle()` — nothing + would ever pull history. +- `0xAB` deframing treats the whole buffer as exactly one frame (`payloadLen = buffer.size - 6`, + then `buffer.clear()`), with no length field. +- `RWfitDriver` holds an encoder whose `setProtocol()` result is discarded; `RWfitSyncEngine` + builds a second, independent one. + +**What was removed** (`ios_sync_2026-08-08`): the six `RWfit*.kt` files, `RingDeviceType.RWFIT`, +`WearableModel.RWFIT` + its catalog entry, the `RWfitCoordinator` registration in `RingBLEClient`, +the `DeviceHeroCard` fallback arm, and the `PairingMatchingTest` registered-type entry. The +gratuitously-deleted CRP ordering comment in `RingBLEClient` was restored. + +**Where the work lives:** `feat/rwfit-ring-family` (bookmarked at `b073dad`). Rebuild the protocol +layer there from `decompiled-rwfit-official/`, then recombine. + +--- + ## Android-originated fixes (2026-07-06 review pass) — upstream candidates for iOS A post-port code review of the sync-triage branch found and fixed the issues below on diff --git a/docs/pr21-ios-parity-handoff.md b/docs/pr21-ios-parity-handoff.md new file mode 100644 index 0000000..567bcc3 --- /dev/null +++ b/docs/pr21-ios-parity-handoff.md @@ -0,0 +1,152 @@ +# PR #21 fixes — iOS parity handoff + +Summary of the Android changes from PR #21 (Colmi sleep + AI Coach), and **what iOS +needs vs. what iOS already has**. Most of the coach work was Android catching up to +iOS, so the action list is short. Background: issue #19 (sleep not tracking, "AI same +response"). File/line references are against the iOS (root) repo at the time of writing. + +--- + +## TL;DR — iOS action list + +| Area | Change | iOS status | +|------|--------|-----------| +| Coach | Gate the `reasoning` field by model | **⚠️ Needs fix — same bug on iOS** | +| Coach | Surface `result.error` as a red bubble | ✅ Already done (Android ported from iOS) | +| Coach | Filter error bubbles out of replay history | ✅ Already done | +| Colmi | Guard the sleep→HRV pipeline advance by stage | **🔸 Recommended — latent duplicate-request on iOS** | +| Colmi | Decouple sleep into an on-demand `syncSleepNow()` | ⚪ Android-only feature — optional to port | +| Colmi | GATT write-queue hardening (retry + reconnect) | ⚪ N/A — Android framework-specific | + +--- + +## Part A — AI Coach + +### A1. Gate the `reasoning` field by model — ⚠️ iOS NEEDS THIS + +**Bug (present on both platforms):** the request builder adds `"reasoning": {"effort": …}` +whenever a reasoning effort is set in Settings, with **no check that the selected model +supports it**. OpenAI's legacy chat models (`gpt-4o`, `gpt-4*`, `gpt-3.5`, `chatgpt-*`) +reject any request carrying `reasoning` with an HTTP 400. Combined with the un-gated +Settings combo (non-reasoning model + a set effort), **every** coach turn failed — which, +because of the error-masking bug (A2, already fixed on iOS), looked like "the same answer +every time" in issue #19. + +**iOS location:** `PulseLoop/Coach/OpenAI/ResponsesTypes.swift:127` + +```swift +if let reasoningEffort, !reasoningEffort.isEmpty { body["reasoning"] = ["effort": reasoningEffort] } +``` + +**Fix (mirror Android):** only attach `reasoning` when the model supports it. Default to +**allowing** it so unknown/future models aren't blocked; suppress only the known +non-reasoning families. Strip any OpenRouter `vendor/model` prefix before matching. + +```swift +// non-reasoning families that 400 on a `reasoning` field +private func modelSupportsReasoning(_ model: String) -> Bool { + let slug = model.lowercased().split(separator: "/").last.map(String.init) ?? model.lowercased() + return !(slug.hasPrefix("gpt-4") || slug.hasPrefix("gpt-3") || slug.hasPrefix("chatgpt")) +} + +// in body(...): +if let reasoningEffort, !reasoningEffort.isEmpty, modelSupportsReasoning(model) { + body["reasoning"] = ["effort": reasoningEffort] +} +``` + +Apply to **both** the chat path (`CoachOrchestrator`) and the summary path +(`CoachSummaryGenerator`) — both call the same builder, so gating it once in +`ResponsesTypes.body(...)` covers them. Android's version: +`OpenAIRequestBuilder.reasoningParams(effort, model)`. + +> Note: this only gates the **OpenAI Responses** builder. If OpenRouter/Gemini/MiniMax +> clients can also emit `reasoning` for a non-reasoning model, give them the same guard. + +### A2. Surface real errors as a red bubble — ✅ iOS already has this + +Android was catching up here. A failed turn returns a fixed `CoachFallbacks.fallback()` +string in `TurnResult.assistant` with the real cause in `TurnResult.error`; Android was +rendering only the fallback and discarding the error, so every failure showed an identical +canned bubble. iOS already renders `result.error` as a distinct `role: "error"` bubble +carrying code + reason — see `CoachViewModel.swift:134`. **No action.** + +### A3. Don't replay error bubbles to the model — ✅ iOS already has this + +Error bubbles are app-generated diagnostics, not real assistant turns; replaying them as +history feeds the model garbage like "Coach error · HTTP 404 …". iOS already filters them: +`CoachViewModel.swift:226` — `.filter { $0.role != "error" }`. Android now filters on an +`isError` flag to match. **No action.** + +> Android-only footnote: Android also had a *duplicate* display bug (the red bubble **and** +> a separate "Error: " footer showed for the same failure). That was specific to the +> Android Compose screen setting `state.error` in addition to the bubble; iOS uses the +> `role: "error"` message as the single surface, so it isn't affected. + +--- + +## Part B — Colmi sleep + +### B1. Guard the sleep→HRV pipeline advance by stage — 🔸 iOS recommended + +**Latent bug on iOS:** `handleBigDataComplete(.bigDataSleep)` advances the staged history +pipeline to HRV **unconditionally**, without checking the current stage. + +**iOS location:** `PulseLoop/RingProtocol/ColmiSyncEngine.swift:259` + +```swift +case ColmiCommandID.bigDataSleep: + stage = .hrv + daysAgo = 0 + requestHRV() + armWatchdog() +``` + +On iOS the failure mode is the **watchdog-skip** case: if the SLEEP stage stalls, the +watchdog `forceAdvanceStage(.sleep)` already sets `stage = .hrv` and requests HRV +(`ColmiSyncEngine.swift:193`). If the real sleep big-data completion then lands late, this +branch runs again → a **duplicate HRV request** mid-pipeline. + +**Fix (mirror Android):** only advance when we're actually on the SLEEP stage. + +```swift +case ColmiCommandID.bigDataSleep: + guard stage == .sleep else { return } // ignore a late/stray sleep completion + stage = .hrv + daysAgo = 0 + requestHRV() + armWatchdog() +``` + +On Android this guard also protects the standalone-sleep race (see B2); iOS doesn't have +that path yet, so for iOS this is purely the duplicate-request hardening — but it's the +same one-line guard and worth taking. + +### B2. Decouple sleep into on-demand `syncSleepNow()` — ⚪ Android-only, optional to port + +Android added a standalone, off-pipeline sleep fetch (QRing parity): opening the Sleep +screen fires a dedicated `bigDataSleep()` request instead of depending on the SLEEP stage +surviving four earlier stages (ACTIVITY→HR→STRESS→SPO2). The standalone completion is +guarded (`sleepOnlyActive`) so it doesn't advance the full-sync pipeline. + +iOS has **no** `syncSleepNow()` — sleep is still fetched only mid-pipeline. This is an +enhancement, not a correctness bug, so it's optional. If you port it, the B1 guard becomes +load-bearing (a standalone reply that lands after a full sync starts must not jump the +pipeline), so land B1 first. + +### B3. GATT write-queue hardening — ⚪ N/A to iOS + +Android hardened its BLE op queue: more persistent write retries (`MAX_OP_ATTEMPTS` 3→6), +and — the key change — forcing a reconnect after a run of dropped/timed-out ops to clear +the Android framework's stuck single-op busy flag (`mDeviceBusy`) instead of spin-dropping +commands. This targets an **Android BluetoothGatt framework** behavior; iOS CoreBluetooth +manages write serialization differently and doesn't have the equivalent wedge. **No iOS +counterpart needed.** + +--- + +## Questions + +Ping me (Android) if any of the iOS references have drifted — line numbers are from a +snapshot. The two that matter are **A1 (reasoning gate — real bug)** and **B1 (sleep-stage +guard — hardening)**.