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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions android/app/src/main/java/com/noop/push/PushCapabilities.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ package com.noop.push
import org.json.JSONArray
import org.json.JSONObject

/** A receiver may only narrow the fixed protocol 1.0 registry, never name new data. */
/** A receiver may only narrow the fixed v1 registry for its selected minor version, never name new data. */
data class PushCapabilities(
val appendTables: Set<PushAppendTable>,
val mutableTables: Set<PushMutableTable>,
Expand Down Expand Up @@ -32,12 +32,13 @@ data class PushCapabilities(
val required = setOf("type", "protocolVersion", "receiverStateId", "streams")
val actualMembers = obj.keys().asSequence().toSet()
if (!actualMembers.containsAll(required)) {
throw PushProtocolException("capabilities are missing required protocol 1.0 members")
throw PushProtocolException("capabilities are missing required members")
}
if (actualMembers.any { it in PushProtocol.FORBIDDEN_REMOTE_CONTROL_MEMBERS }) {
throw PushProtocolException("capabilities contain forbidden remote-control metadata")
}
if (obj.opt("type") != "capabilities" || obj.opt("protocolVersion") != PushProtocol.VERSION) {
val protocolVersion = obj.opt("protocolVersion") as? String
if (obj.opt("type") != "capabilities" || protocolVersion == null || protocolVersion !in PushProtocol.SUPPORTED_VERSIONS) {
throw PushProtocolException("unsupported capability document")
}
val receiverStateId = (obj.opt("receiverStateId") as? String)?.takeIf(::isCanonicalUuid)
Expand All @@ -59,7 +60,7 @@ data class PushCapabilities(
else -> throw PushProtocolException("unknown capability stream")
}
}
return PushCapabilities(append, mutable, PushProtocol.VERSION, receiverStateId)
return PushCapabilities(append, mutable, protocolVersion!!, receiverStateId)
}

private fun isCanonicalUuid(value: String): Boolean = runCatching {
Expand Down
28 changes: 18 additions & 10 deletions android/app/src/main/java/com/noop/push/PushCoordinator.kt
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,11 @@ class PushCoordinator(
private val zoneId: ZoneId,
private val destinationStillCurrent: () -> Boolean = { true },
) {
suspend fun pushAppend(table: PushAppendTable, deviceId: String): PushResult {
suspend fun pushAppend(
table: PushAppendTable,
deviceId: String,
protocolVersion: String = PushProtocol.VERSION,
): PushResult {
val stored = try {
progress.cursor(table, deviceId)
} catch (cancelled: CancellationException) {
Expand Down Expand Up @@ -49,7 +53,7 @@ class PushCoordinator(
}
if (rows.isEmpty()) return PushResult.NoData
val batch = try {
PushProtocol.appendBatch(table, sourceId, deviceId, effective, rows)
PushProtocol.appendBatch(table, sourceId, deviceId, effective, rows, protocolVersion)
} catch (t: PushProtocolException) {
return rejected(PushFailure(PushFailureCode.LOCAL_DATA))
}
Expand All @@ -69,11 +73,15 @@ class PushCoordinator(
}
}

suspend fun pushMutable(table: PushMutableTable, deviceId: String): PushResult {
suspend fun pushMutable(
table: PushMutableTable,
deviceId: String,
protocolVersion: String = PushProtocol.VERSION,
): PushResult {
val fullWindow = PushWindow.ending(today(), zoneId)
val rows = try {
source.mutableRows(
table, deviceId, fullWindow, PushProtocol.MAX_MUTABLE_SNAPSHOT_RECORDS + 1,
table, deviceId, fullWindow, PushProtocol.MAX_MUTABLE_SNAPSHOT_RECORDS + 1, protocolVersion,
)
} catch (cancelled: CancellationException) {
throw cancelled
Expand All @@ -92,7 +100,7 @@ class PushCoordinator(
val recordsByDay = days.associateWith { mutableListOf<PushMutableRecord>() }
for (record in rows) {
val size = try {
PushProtocol.mutableRecordEncodedSize(table, record)
PushProtocol.mutableRecordEncodedSize(table, record, protocolVersion)
} catch (t: PushProtocolException) {
return rejected(PushFailure(PushFailureCode.LOCAL_DATA))
}
Expand All @@ -111,7 +119,7 @@ class PushCoordinator(
}
val currentHashes = try {
recordsByDay.mapKeys { (day, _) -> day.toString() }
.mapValues { (_, dayRows) -> PushProtocol.mutableSnapshotHash(table, dayRows) }
.mapValues { (_, dayRows) -> PushProtocol.mutableSnapshotHash(table, dayRows, protocolVersion) }
} catch (_: PushProtocolException) {
return rejected(PushFailure(PushFailureCode.LOCAL_DATA))
}
Expand All @@ -130,7 +138,7 @@ class PushCoordinator(
.flatMap { recordsByDay.getValue(it).asSequence() }
.toList()
val batches = try {
PushProtocol.mutableBatches(table, sourceId, deviceId, window, changedRows)
PushProtocol.mutableBatches(table, sourceId, deviceId, window, changedRows, protocolVersion)
} catch (t: PushProtocolException) {
return rejected(PushFailure(PushFailureCode.LOCAL_DATA))
}
Expand Down Expand Up @@ -215,7 +223,7 @@ class PushCoordinator(
var selectedFailure: PushFailure? = null
for (deviceId in selectedDevices) {
for (table in PushAppendTable.entries.filter { it in capabilities.appendTables }) {
when (val result = pushAppend(table, deviceId)) {
when (val result = pushAppend(table, deviceId, capabilities.protocolVersion)) {
is PushResult.Accepted -> {
accepted += result.batchCount
acceptedRecords += result.recordCount
Expand All @@ -232,7 +240,7 @@ class PushCoordinator(
}
}
for (table in PushMutableTable.entries.filter { it in capabilities.mutableTables }) {
when (val result = pushMutable(table, deviceId)) {
when (val result = pushMutable(table, deviceId, capabilities.protocolVersion)) {
is PushResult.Accepted -> {
accepted += result.batchCount
acceptedRecords += result.recordCount
Expand Down Expand Up @@ -282,7 +290,7 @@ class PushCoordinator(
)
}
val ack = try {
PushAck.parse(response.body)
PushAck.parse(response.body, batch.protocolVersion)
} catch (t: PushProtocolException) {
return rejected(PushFailure(PushFailureCode.ACK_INVALID))
}
Expand Down
123 changes: 114 additions & 9 deletions android/app/src/main/java/com/noop/push/PushDao.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import android.database.Cursor
import androidx.room.withTransaction
import androidx.sqlite.db.SimpleSQLiteQuery
import com.noop.data.WhoopDatabase
import java.time.LocalDate
import java.time.ZoneId

/** SQL-side upper bound evaluated before Android materializes any unrestricted TEXT value. */
internal object PushSnapshotPreflight {
Expand All @@ -22,9 +24,14 @@ internal object PushSnapshotPreflight {
}
}

fun query(table: String, columns: List<String>, predicate: String, orderBy: String): String =
"SELECT COALESCE(SUM(${rowEstimateExpression(columns)}), 0) FROM " +
"(SELECT ${columns.joinToString()} FROM $table WHERE $predicate ORDER BY $orderBy LIMIT ?)"
fun query(
table: String,
columns: List<String>,
predicate: String,
orderBy: String,
projections: List<String> = columns,
): String = "SELECT COALESCE(SUM(${rowEstimateExpression(columns)}), 0) FROM " +
"(SELECT ${projections.joinToString()} FROM $table WHERE $predicate ORDER BY $orderBy LIMIT ?)"
}

internal object PushDeviceDiscovery {
Expand All @@ -44,7 +51,7 @@ internal object PushDeviceDiscovery {
class PushDao internal constructor(private val db: WhoopDatabase) : PushSnapshotSource {
override suspend fun knownDeviceIds(capabilities: PushCapabilities): List<String> = db.withTransaction {
val supportedTables = capabilities.appendTables.map(::appendSpec) +
capabilities.mutableTables.map(::mutableSpec)
capabilities.mutableTables.map { mutableSpec(it, capabilities.protocolVersion) }
val sql = PushDeviceDiscovery.query(supportedTables.map(TableSpec::sqlName))
db.query(SimpleSQLiteQuery(sql)).use { cursor ->
buildList {
Expand Down Expand Up @@ -102,9 +109,17 @@ class PushDao internal constructor(private val db: WhoopDatabase) : PushSnapshot
deviceId: String,
window: PushWindow,
limit: Int,
): List<PushMutableRecord> = mutableRows(table, deviceId, window, limit, PushProtocol.VERSION)

override suspend fun mutableRows(
table: PushMutableTable,
deviceId: String,
window: PushWindow,
limit: Int,
protocolVersion: String,
): List<PushMutableRecord> {
require(limit in 1..(PushProtocol.MAX_MUTABLE_SNAPSHOT_RECORDS + 1))
val spec = mutableSpec(table)
val spec = mutableSpec(table, protocolVersion)
return db.withTransaction {
val (predicate, bounds) = when (table) {
PushMutableTable.DAILY_METRIC, PushMutableTable.JOURNAL ->
Expand All @@ -113,7 +128,7 @@ class PushDao internal constructor(private val db: WhoopDatabase) : PushSnapshot
"startTs >= ? AND startTs < ?" to
arrayOf<Any?>(window.startTsInclusive, window.endTsExclusive)
}
val sql = "SELECT ${spec.columns.joinToString()} FROM ${spec.sqlName} " +
val sql = "SELECT ${spec.projections.joinToString()} FROM ${spec.sqlName} " +
"WHERE deviceId = ? AND $predicate ORDER BY ${spec.keyColumns.joinToString()} ASC LIMIT ?"
val args = arrayOfNulls<Any?>(bounds.size + 2)
args[0] = deviceId
Expand All @@ -125,17 +140,91 @@ class PushDao internal constructor(private val db: WhoopDatabase) : PushSnapshot
spec.columns,
"deviceId = ? AND $predicate",
"${spec.keyColumns.joinToString()} ASC",
spec.projections,
),
args,
)
db.query(SimpleSQLiteQuery(sql, args)).use { cursor ->
val records = db.query(SimpleSQLiteQuery(sql, args)).use { cursor ->
buildList {
while (cursor.moveToNext()) add(cursor.mutableRecord(spec))
}
}
if (table != PushMutableTable.DAILY_METRIC ||
protocolVersion != PushProtocol.LATEST_VERSION || records.isEmpty()
) {
records
} else if (records.last().data["sleepConsistency"] != null) {
// SleepModel prefers the imported consistency series as a whole when its latest day has a
// point; don't mix imported values and the locally calculated fallback in one series.
records
} else {
// When the latest day has no imported consistency score, SleepModel uses the local
// bedtime-spread calculation for the series. Never substitute recovery or performance.
val computedConsistency = sleepConsistencyByWakeDay(deviceId, window)
records.map { record ->
val day = record.key["day"] as? String
record.copy(data = record.data + ("sleepConsistency" to day?.let(computedConsistency::get)))
}
}
}
}

/**
* Compute the existing trailing-14 bedtime-onset score for each local wake day in the export window.
* Fetching the previous 13 sessions before the first in-window wake keeps the first exported score
* identical to the Sleep screen without loading unbounded sleep history.
*/
private fun sleepConsistencyByWakeDay(deviceId: String, window: PushWindow): Map<String, Double> {
val zone = ZoneId.systemDefault()
val start = LocalDate.parse(window.fromDay).atStartOfDay(zone).toEpochSecond()
val end = LocalDate.parse(window.toDay).plusDays(1).atStartOfDay(zone).toEpochSecond()
val inWindow = db.query(
SimpleSQLiteQuery(
"SELECT startTs, endTs, startTsAdjusted FROM sleepSession " +
"WHERE deviceId = ? AND endTs >= ? AND endTs < ? ORDER BY startTs ASC LIMIT ?",
arrayOf(deviceId, start, end, PushProtocol.MAX_MUTABLE_SNAPSHOT_RECORDS + 1),
),
).use { cursor ->
buildList {
while (cursor.moveToNext()) {
val adjusted = cursor.getColumnIndexOrThrow("startTsAdjusted")
add(
SleepConsistencySession(
startTs = cursor.getLong(cursor.getColumnIndexOrThrow("startTs")),
endTs = cursor.getLong(cursor.getColumnIndexOrThrow("endTs")),
startTsAdjusted = if (cursor.isNull(adjusted)) null else cursor.getLong(adjusted),
),
)
}
}
}
if (inWindow.size > PushProtocol.MAX_MUTABLE_SNAPSHOT_RECORDS) {
throw PushProtocolException("sleep consistency snapshot exceeds local row limit")
}
val firstStartTs = inWindow.firstOrNull()?.startTs ?: return emptyMap()
val prior = db.query(
SimpleSQLiteQuery(
"SELECT startTs, endTs, startTsAdjusted FROM sleepSession " +
"WHERE deviceId = ? AND startTs < ? ORDER BY startTs DESC LIMIT 13",
arrayOf(deviceId, firstStartTs),
),
).use { cursor ->
buildList {
while (cursor.moveToNext()) {
val adjusted = cursor.getColumnIndexOrThrow("startTsAdjusted")
add(
SleepConsistencySession(
startTs = cursor.getLong(cursor.getColumnIndexOrThrow("startTs")),
endTs = cursor.getLong(cursor.getColumnIndexOrThrow("endTs")),
startTsAdjusted = if (cursor.isNull(adjusted)) null else cursor.getLong(adjusted),
),
)
}
}
}.asReversed()
return SleepConsistencyPushProjection.byWakeDay(prior + inWindow, zone)
}

private fun ensureSnapshotBounded(sql: String, args: Array<Any?>) {
val estimate = db.query(SimpleSQLiteQuery(sql, args)).use { cursor ->
if (cursor.moveToFirst()) cursor.getLong(0) else 0L
Expand Down Expand Up @@ -182,8 +271,10 @@ class PushDao internal constructor(private val db: WhoopDatabase) : PushSnapshot
val keyColumns: List<String>,
val dataColumns: List<String>,
val booleanColumns: Set<String> = emptySet(),
val projectionOverrides: Map<String, String> = emptyMap(),
) {
val columns: List<String> = keyColumns + dataColumns
val projections: List<String> = columns.map { projectionOverrides[it] ?: it }
}

private fun appendSpec(table: PushAppendTable): TableSpec = when (table) {
Expand All @@ -197,8 +288,11 @@ class PushDao internal constructor(private val db: WhoopDatabase) : PushSnapshot
PushAppendTable.GRAVITY_SAMPLE -> GRAVITY
}

private fun mutableSpec(table: PushMutableTable): TableSpec = when (table) {
PushMutableTable.DAILY_METRIC -> DAILY
private fun mutableSpec(
table: PushMutableTable,
protocolVersion: String = PushProtocol.VERSION,
): TableSpec = when (table) {
PushMutableTable.DAILY_METRIC -> if (protocolVersion == PushProtocol.LATEST_VERSION) DAILY_V11 else DAILY
PushMutableTable.SLEEP_SESSION -> SLEEP
PushMutableTable.WORKOUT -> WORKOUT
PushMutableTable.JOURNAL -> JOURNAL
Expand Down Expand Up @@ -237,6 +331,17 @@ class PushDao internal constructor(private val db: WhoopDatabase) : PushSnapshot
"skinTempDevC", "respRateBpm", "steps", "activeKcalEst", "spo2Red", "spo2Ir",
),
)
val DAILY_V11 = DAILY.copy(
dataColumns = DAILY.dataColumns + listOf("sleepPerformance", "sleepConsistency"),
projectionOverrides = mapOf(
"sleepPerformance" to "(SELECT value FROM metricSeries AS pushScore " +
"WHERE pushScore.deviceId = dailyMetric.deviceId AND pushScore.day = dailyMetric.day " +
"AND pushScore.key = 'sleep_performance' LIMIT 1) AS sleepPerformance",
"sleepConsistency" to "(SELECT value FROM metricSeries AS pushConsistency " +
"WHERE pushConsistency.deviceId = dailyMetric.deviceId AND pushConsistency.day = dailyMetric.day " +
"AND pushConsistency.key = 'sleep_consistency' LIMIT 1) AS sleepConsistency",
),
)
val SLEEP = TableSpec(
"sleepSession",
listOf("startTs"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ class PushHttpTransport(
.url(endpoint.url)
.header("Authorization", "Bearer $bearerToken")
.header("Accept", "application/json")
.header(ACCEPT_VERSION_HEADER, PushProtocol.VERSION)
.header(ACCEPT_VERSION_HEADER, PushProtocol.SUPPORTED_VERSIONS.joinToString(", "))
.get()
.build()
val response = try {
Expand Down
9 changes: 9 additions & 0 deletions android/app/src/main/java/com/noop/push/PushModels.kt
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,15 @@ interface PushSnapshotSource {
window: PushWindow,
limit: Int,
): List<PushMutableRecord>

/** Newer wire versions may add an explicit projection without broadening older snapshots. */
suspend fun mutableRows(
table: PushMutableTable,
deviceId: String,
window: PushWindow,
limit: Int,
protocolVersion: String,
): List<PushMutableRecord> = mutableRows(table, deviceId, window, limit)
}

sealed interface PushResult {
Expand Down
Loading
Loading