Compare commits

...

3 commits

72 changed files with 2848 additions and 95 deletions

View file

@ -2,6 +2,22 @@
## Unreleased — 0.1.5
- Added the Cardio V1 persistence/synchronization foundation. Android schema
v27 owns durable timestamped heart-rate captures, samples and raw RR values;
desktop schema v30 imports the optional `trainlog-heart-rate` V1 companion
transactionally. Exact generation membership is retained until a correlated
consumed ACK, after which those stopped captures remain local history but are
excluded from ordinary re-publication. Desktop/Web remain sensor consumers
only; Bluetooth Classic Trainlog synchronization is unchanged.
- Added the Android Heart Rate Service diagnostic foundation. The independent
`0x2A37` parser covers 8/16-bit BPM, contact, energy and raw RR flags with
strict malformed-payload rejection. A settings diagnostic provides bounded
HRS-filtered and expanded BLE scans, GATT service discovery and notification
subscription without turning desktop/Web into sensor clients. Android 12+
scan/connect permissions and the Android 11-and-older scan-location boundary
are explicit. Real armband notification characterization remains a hardware
validation gate before persistent acquisition.
Development opened after stable v0.1.4 release.
## 0.1.4 — 2026-09-22

View file

@ -36,7 +36,7 @@ Programs. Sessions → Programmes remains the technical administration/import,
list, detail, archive, and delete surface.
Web Exercises administration, the factual Dashboard/Analyse read model, and the
synchronized Sleep Diary workspace are the stable v0.1.4 baseline. Version
0.1.5 development is focused on cardio and heart-rate sensor capture.
0.1.5 development has the Android/desktop heart-rate persistence and synchronization foundation in place; Android BLE sensor acquisition is the current implementation cursor.
## Releases

View file

@ -12,6 +12,14 @@
<uses-permission
android:name="android.permission.BLUETOOTH_ADMIN"
android:maxSdkVersion="30" />
<!-- BLE cardio discovery does not derive physical location. Android 11 and
lower still require fine location for direct BLE scans. -->
<uses-permission
android:name="android.permission.ACCESS_FINE_LOCATION"
android:maxSdkVersion="30" />
<uses-permission
android:name="android.permission.BLUETOOTH_SCAN"
android:usesPermissionFlags="neverForLocation" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />

View file

@ -225,7 +225,7 @@ internal class AndroidBackupService(
manifest.getInt("version") == VERSION &&
manifest.getString("package") == context.packageName &&
manifest.getBoolean("plaintext") &&
manifest.getInt("schema") in 17..26
manifest.getInt("schema") in 17..27
) {
"Unsupported backup."
}
@ -319,7 +319,7 @@ internal class AndroidBackupService(
}
)
require(db.rawQuery("PRAGMA foreign_key_check", null).use { !it.moveToFirst() })
require(db.version in 17..26) { "Unsupported Android schema ${db.version}." }
require(db.version in 17..27) { "Unsupported Android schema ${db.version}." }
return db.version
} finally {
db.close()

View file

@ -0,0 +1,482 @@
/*
* Android-only BLE Heart Rate diagnostics.
*
* This owner is intentionally bounded and screen-scoped. Long-running capture
* belongs to the later foreground acquisition service, not this diagnostic.
*/
package com.labfytools.trainlog.data
import android.Manifest
import android.annotation.SuppressLint
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothGatt
import android.bluetooth.BluetoothGattCallback
import android.bluetooth.BluetoothGattCharacteristic
import android.bluetooth.BluetoothGattDescriptor
import android.bluetooth.BluetoothManager
import android.bluetooth.BluetoothProfile
import android.bluetooth.BluetoothStatusCodes
import android.bluetooth.le.ScanCallback
import android.bluetooth.le.ScanFilter
import android.bluetooth.le.ScanResult
import android.bluetooth.le.ScanSettings
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.os.ParcelUuid
import androidx.core.content.ContextCompat
import java.util.UUID
internal val HEART_RATE_SERVICE_UUID: UUID =
UUID.fromString("0000180d-0000-1000-8000-00805f9b34fb")
internal val HEART_RATE_MEASUREMENT_UUID: UUID =
UUID.fromString("00002a37-0000-1000-8000-00805f9b34fb")
internal val BATTERY_SERVICE_UUID: UUID =
UUID.fromString("0000180f-0000-1000-8000-00805f9b34fb")
private val CLIENT_CHARACTERISTIC_CONFIG_UUID: UUID =
UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
internal enum class HeartRateDiagnosticPhase {
IDLE,
SCANNING,
CONNECTING,
DISCOVERING,
SUBSCRIBING,
RECEIVING,
DISCONNECTED,
ERROR,
}
internal data class HeartRateDiagnosticDevice(
val address: String,
val name: String?,
val rssi: Int,
val advertisesHeartRate: Boolean,
)
internal data class HeartRateDiagnosticSnapshot(
val phase: HeartRateDiagnosticPhase = HeartRateDiagnosticPhase.IDLE,
val devices: List<HeartRateDiagnosticDevice> = emptyList(),
val selectedAddress: String? = null,
val serviceFound: Boolean = false,
val measurementFound: Boolean = false,
val batteryServiceFound: Boolean = false,
val notificationCount: Int = 0,
val latestMeasurement: ParsedHeartRateMeasurement? = null,
val diagnostic: String? = null,
)
internal fun heartRateBleRuntimePermissions(): Array<String> =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
arrayOf(Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_CONNECT)
} else {
arrayOf(Manifest.permission.ACCESS_FINE_LOCATION)
}
internal fun hasHeartRateBleRuntimePermissions(context: Context): Boolean =
heartRateBleRuntimePermissions().all {
ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED
}
internal class HeartRateBleDiagnostics(
context: Context,
private val onSnapshot: (HeartRateDiagnosticSnapshot) -> Unit,
) : AutoCloseable {
private val appContext = context.applicationContext
private val main = Handler(Looper.getMainLooper())
private val bluetoothManager =
appContext.getSystemService(BluetoothManager::class.java)
private val adapter get() = bluetoothManager?.adapter
private val devices = linkedMapOf<String, BluetoothDevice>()
private var snapshot = HeartRateDiagnosticSnapshot()
private var scannerCallback: ScanCallback? = null
private var scanStop: Runnable? = null
private var gatt: BluetoothGatt? = null
private var closed = false
private fun update(transform: (HeartRateDiagnosticSnapshot) -> HeartRateDiagnosticSnapshot) {
if (closed) return
if (Looper.myLooper() != Looper.getMainLooper()) {
main.post { update(transform) }
return
}
snapshot = transform(snapshot)
onSnapshot(snapshot)
}
@SuppressLint("MissingPermission")
fun startScan(includeAllBle: Boolean = false) {
if (!hasHeartRateBleRuntimePermissions(appContext)) {
update { it.copy(phase = HeartRateDiagnosticPhase.ERROR, diagnostic = "permission") }
return
}
val currentAdapter = adapter
if (currentAdapter == null) {
update { it.copy(phase = HeartRateDiagnosticPhase.ERROR, diagnostic = "bluetooth_unavailable") }
return
}
if (!currentAdapter.isEnabled) {
update { it.copy(phase = HeartRateDiagnosticPhase.ERROR, diagnostic = "bluetooth_disabled") }
return
}
val scanner = currentAdapter.bluetoothLeScanner
if (scanner == null) {
update { it.copy(phase = HeartRateDiagnosticPhase.ERROR, diagnostic = "scanner_unavailable") }
return
}
stopScan()
devices.clear()
update {
HeartRateDiagnosticSnapshot(
phase = HeartRateDiagnosticPhase.SCANNING,
diagnostic = if (includeAllBle) "scan_all" else "scan_heart_rate",
)
}
val callback =
object : ScanCallback() {
override fun onScanResult(callbackType: Int, result: ScanResult) {
handleScanResult(result)
}
override fun onBatchScanResults(results: MutableList<ScanResult>) {
results.forEach(::handleScanResult)
}
override fun onScanFailed(errorCode: Int) {
update {
it.copy(
phase = HeartRateDiagnosticPhase.ERROR,
diagnostic = "scan_failed:" + errorCode,
)
}
}
}
scannerCallback = callback
val filters =
if (includeAllBle) {
emptyList()
} else {
listOf(
ScanFilter.Builder()
.setServiceUuid(ParcelUuid(HEART_RATE_SERVICE_UUID))
.build(),
)
}
scanner.startScan(
filters,
ScanSettings.Builder().setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY).build(),
callback,
)
val stopper =
Runnable {
if (snapshot.phase == HeartRateDiagnosticPhase.SCANNING) {
stopScan()
update { it.copy(phase = HeartRateDiagnosticPhase.IDLE, diagnostic = "scan_complete") }
}
}
scanStop = stopper
main.postDelayed(stopper, SCAN_DURATION_MS)
}
@SuppressLint("MissingPermission")
private fun handleScanResult(result: ScanResult) {
val record = result.scanRecord
val advertised =
record?.serviceUuids?.any { it.uuid == HEART_RATE_SERVICE_UUID } == true
val device = result.device
devices[device.address] = device
val row =
HeartRateDiagnosticDevice(
address = device.address,
name = record?.deviceName,
rssi = result.rssi,
advertisesHeartRate = advertised,
)
update { old ->
val rows =
(old.devices.filterNot { it.address == row.address } + row)
.sortedWith(
compareByDescending<HeartRateDiagnosticDevice> { it.advertisesHeartRate }
.thenByDescending { it.rssi },
)
old.copy(devices = rows)
}
}
@SuppressLint("MissingPermission")
fun connect(address: String) {
if (!hasHeartRateBleRuntimePermissions(appContext)) {
update { it.copy(phase = HeartRateDiagnosticPhase.ERROR, diagnostic = "permission") }
return
}
val device =
devices[address]
?: runCatching { adapter?.getRemoteDevice(address) }.getOrNull()
?: run {
update { it.copy(phase = HeartRateDiagnosticPhase.ERROR, diagnostic = "device_missing") }
return
}
stopScan()
closeGatt()
update {
it.copy(
phase = HeartRateDiagnosticPhase.CONNECTING,
selectedAddress = address,
serviceFound = false,
measurementFound = false,
batteryServiceFound = false,
notificationCount = 0,
latestMeasurement = null,
diagnostic = "connecting",
)
}
@Suppress("DEPRECATION")
runCatching {
device.connectGatt(
appContext,
false,
gattCallback,
BluetoothDevice.TRANSPORT_LE,
)
}
.onSuccess { gatt = it }
.onFailure { error ->
update {
it.copy(
phase = HeartRateDiagnosticPhase.ERROR,
diagnostic = "connect_failed:" + error.javaClass.simpleName,
)
}
}
}
private val gattCallback =
object : BluetoothGattCallback() {
@SuppressLint("MissingPermission")
override fun onConnectionStateChange(
callbackGatt: BluetoothGatt,
status: Int,
newState: Int,
) {
main.post {
if (closed || callbackGatt !== gatt) return@post
if (status == BluetoothGatt.GATT_SUCCESS &&
newState == BluetoothProfile.STATE_CONNECTED
) {
update {
it.copy(
phase = HeartRateDiagnosticPhase.DISCOVERING,
diagnostic = "connected",
)
}
if (!callbackGatt.discoverServices()) {
update {
it.copy(
phase = HeartRateDiagnosticPhase.ERROR,
diagnostic = "discover_start_failed",
)
}
}
} else if (newState == BluetoothProfile.STATE_DISCONNECTED) {
update {
it.copy(
phase = HeartRateDiagnosticPhase.DISCONNECTED,
diagnostic = "disconnected:" + status,
)
}
closeGatt()
} else if (status != BluetoothGatt.GATT_SUCCESS) {
update {
it.copy(
phase = HeartRateDiagnosticPhase.ERROR,
diagnostic = "gatt_status:" + status,
)
}
closeGatt()
}
}
}
@SuppressLint("MissingPermission")
override fun onServicesDiscovered(callbackGatt: BluetoothGatt, status: Int) {
main.post {
if (closed || callbackGatt !== gatt) return@post
if (status != BluetoothGatt.GATT_SUCCESS) {
update {
it.copy(
phase = HeartRateDiagnosticPhase.ERROR,
diagnostic = "service_discovery:" + status,
)
}
return@post
}
val service = callbackGatt.getService(HEART_RATE_SERVICE_UUID)
val measurement = service?.getCharacteristic(HEART_RATE_MEASUREMENT_UUID)
val battery = callbackGatt.getService(BATTERY_SERVICE_UUID) != null
update {
it.copy(
serviceFound = service != null,
measurementFound = measurement != null,
batteryServiceFound = battery,
)
}
if (measurement == null) {
update {
it.copy(
phase = HeartRateDiagnosticPhase.ERROR,
diagnostic = "heart_rate_measurement_missing",
)
}
return@post
}
subscribe(callbackGatt, measurement)
}
}
override fun onDescriptorWrite(
callbackGatt: BluetoothGatt,
descriptor: BluetoothGattDescriptor,
status: Int,
) {
if (descriptor.uuid != CLIENT_CHARACTERISTIC_CONFIG_UUID) return
update {
if (status == BluetoothGatt.GATT_SUCCESS) {
it.copy(
phase = HeartRateDiagnosticPhase.RECEIVING,
diagnostic = "notifications_enabled",
)
} else {
it.copy(
phase = HeartRateDiagnosticPhase.ERROR,
diagnostic = "descriptor_write:" + status,
)
}
}
}
@Deprecated("Deprecated by Android API 33 callback with explicit value")
override fun onCharacteristicChanged(
callbackGatt: BluetoothGatt,
characteristic: BluetoothGattCharacteristic,
) {
@Suppress("DEPRECATION")
val value = characteristic.value ?: return
handleMeasurement(characteristic.uuid, value)
}
override fun onCharacteristicChanged(
callbackGatt: BluetoothGatt,
characteristic: BluetoothGattCharacteristic,
value: ByteArray,
) {
handleMeasurement(characteristic.uuid, value)
}
}
@SuppressLint("MissingPermission")
private fun subscribe(
callbackGatt: BluetoothGatt,
measurement: BluetoothGattCharacteristic,
) {
if (!callbackGatt.setCharacteristicNotification(measurement, true)) {
update {
it.copy(
phase = HeartRateDiagnosticPhase.ERROR,
diagnostic = "notification_enable_failed",
)
}
return
}
val descriptor = measurement.getDescriptor(CLIENT_CHARACTERISTIC_CONFIG_UUID)
if (descriptor == null) {
update {
it.copy(
phase = HeartRateDiagnosticPhase.ERROR,
diagnostic = "ccc_descriptor_missing",
)
}
return
}
update {
it.copy(
phase = HeartRateDiagnosticPhase.SUBSCRIBING,
diagnostic = "enabling_notifications",
)
}
val started =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
callbackGatt.writeDescriptor(
descriptor,
BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE,
) == BluetoothStatusCodes.SUCCESS
} else {
@Suppress("DEPRECATION")
run {
descriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
callbackGatt.writeDescriptor(descriptor)
}
}
if (!started) {
update {
it.copy(
phase = HeartRateDiagnosticPhase.ERROR,
diagnostic = "descriptor_write_start_failed",
)
}
}
}
private fun handleMeasurement(uuid: UUID, value: ByteArray) {
if (uuid != HEART_RATE_MEASUREMENT_UUID) return
when (val parsed = HeartRateMeasurementParser.parse(value)) {
is HeartRateMeasurementParseResult.Invalid ->
update {
it.copy(
phase = HeartRateDiagnosticPhase.ERROR,
diagnostic = "measurement:" + parsed.reason,
)
}
is HeartRateMeasurementParseResult.Parsed ->
update {
it.copy(
phase = HeartRateDiagnosticPhase.RECEIVING,
notificationCount = it.notificationCount + 1,
latestMeasurement = parsed.measurement,
diagnostic = "measurement",
)
}
}
}
@SuppressLint("MissingPermission")
fun stopScan() {
val callback = scannerCallback ?: return
scanStop?.let(main::removeCallbacks)
scanStop = null
runCatching { adapter?.bluetoothLeScanner?.stopScan(callback) }
scannerCallback = null
}
@SuppressLint("MissingPermission")
private fun closeGatt() {
val current = gatt
gatt = null
runCatching { current?.disconnect() }
runCatching { current?.close() }
}
override fun close() {
if (closed) return
stopScan()
closeGatt()
closed = true
}
private companion object {
const val SCAN_DURATION_MS = 10_000L
}
}

View file

@ -0,0 +1,103 @@
/*
* Bluetooth Heart Rate Measurement parser.
*
* This file deliberately has no Android Bluetooth dependency so protocol
* decoding can be exhaustively unit-tested independently of GATT transport.
*/
package com.labfytools.trainlog.data
internal data class ParsedHeartRateMeasurement(
val bpm: Int,
val sensorContactDetected: Boolean?,
val energyExpended: Int?,
/** Raw Bluetooth Heart Rate Service units: 1/1024 second. */
val rrIntervals1024: List<Int>,
)
internal sealed interface HeartRateMeasurementParseResult {
data class Parsed(val measurement: ParsedHeartRateMeasurement) :
HeartRateMeasurementParseResult
data class Invalid(val reason: String) : HeartRateMeasurementParseResult
}
internal object HeartRateMeasurementParser {
private const val FLAG_HEART_RATE_16_BIT = 1 shl 0
private const val FLAG_CONTACT_DETECTED = 1 shl 1
private const val FLAG_CONTACT_SUPPORTED = 1 shl 2
private const val FLAG_ENERGY_PRESENT = 1 shl 3
private const val FLAG_RR_PRESENT = 1 shl 4
private const val KNOWN_FLAGS =
FLAG_HEART_RATE_16_BIT or FLAG_CONTACT_DETECTED or FLAG_CONTACT_SUPPORTED or
FLAG_ENERGY_PRESENT or FLAG_RR_PRESENT
fun parse(payload: ByteArray): HeartRateMeasurementParseResult {
if (payload.isEmpty()) return HeartRateMeasurementParseResult.Invalid("missing flags")
val flags = payload[0].toInt() and 0xff
if ((flags and KNOWN_FLAGS.inv()) != 0) {
return HeartRateMeasurementParseResult.Invalid("reserved flags are set")
}
var offset = 1
fun uint8(): Int? =
payload.getOrNull(offset)?.let {
offset += 1
it.toInt() and 0xff
}
fun uint16(): Int? {
if (offset + 2 > payload.size) return null
val value =
(payload[offset].toInt() and 0xff) or
((payload[offset + 1].toInt() and 0xff) shl 8)
offset += 2
return value
}
val bpm =
if ((flags and FLAG_HEART_RATE_16_BIT) != 0) {
uint16() ?: return HeartRateMeasurementParseResult.Invalid("truncated 16-bit BPM")
} else {
uint8() ?: return HeartRateMeasurementParseResult.Invalid("missing BPM")
}
val contact =
if ((flags and FLAG_CONTACT_SUPPORTED) == 0) {
null
} else {
(flags and FLAG_CONTACT_DETECTED) != 0
}
val energy =
if ((flags and FLAG_ENERGY_PRESENT) != 0) {
uint16()
?: return HeartRateMeasurementParseResult.Invalid(
"truncated energy-expended value",
)
} else {
null
}
val rr = mutableListOf<Int>()
if ((flags and FLAG_RR_PRESENT) != 0) {
if (offset == payload.size) {
return HeartRateMeasurementParseResult.Invalid("RR flag has no interval")
}
if ((payload.size - offset) % 2 != 0) {
return HeartRateMeasurementParseResult.Invalid("truncated RR interval")
}
while (offset < payload.size) {
rr += checkNotNull(uint16())
}
} else if (offset != payload.size) {
return HeartRateMeasurementParseResult.Invalid("unexpected trailing bytes")
}
return HeartRateMeasurementParseResult.Parsed(
ParsedHeartRateMeasurement(
bpm = bpm,
sensorContactDetected = contact,
energyExpended = energy,
rrIntervals1024 = rr,
),
)
}
}

View file

@ -69,6 +69,7 @@ internal class SyncGenerationService(private val repository: TrainlogRepository)
"program-executions" to
Kind("trainlog-program-executions", 1, "program-executions-v1.json", false),
"sleep-diary" to Kind("trainlog-sleep-diary", 1, "sleep-diary-v1.json", false),
"heart-rate" to Kind("trainlog-heart-rate", 1, "heart-rate-v1.json", false),
)
private val SUPPORTED =
(ANDROID_KINDS.values +
@ -422,6 +423,14 @@ internal class SyncGenerationService(private val repository: TrainlogRepository)
arrayOf(generationId, entry.getString("entry_id"), entry.getString("revision_id")),
)
}
val heartRate = JSONObject(checkNotNull(artifacts["heart-rate"]))
val captures = heartRate.getJSONArray("captures")
for (index in 0 until captures.length()) {
db.execSQL(
"INSERT INTO heart_rate_generation_captures(generation_id,capture_id) VALUES(?,?)",
arrayOf(generationId, captures.getJSONObject(index).getString("capture_id")),
)
}
}
return CapturedSyncGeneration(generationId, runId, digest, stage)
} catch (error: Exception) {
@ -1059,6 +1068,14 @@ internal class SyncGenerationService(private val repository: TrainlogRepository)
arrayOf(ack.getString("generation_id"), ack.getString("consumed_at"),
ack.getString("generation_id")),
)
db.execSQL(
"UPDATE heart_rate_captures SET acknowledged_at=? WHERE " +
"acknowledged_at IS NULL AND EXISTS(" +
"SELECT 1 FROM heart_rate_generation_captures m " +
"WHERE m.generation_id=? AND " +
"m.capture_id=heart_rate_captures.capture_id)",
arrayOf(ack.getString("consumed_at"), ack.getString("generation_id")),
)
}
target
}

View file

@ -22,6 +22,10 @@ import com.labfytools.trainlog.model.ExerciseProfile
import com.labfytools.trainlog.model.ExerciseEditInput
import com.labfytools.trainlog.model.ExerciseFeedback
import com.labfytools.trainlog.model.DraftExerciseFeedback
import com.labfytools.trainlog.model.HeartRateCapture
import com.labfytools.trainlog.model.HeartRateContextKind
import com.labfytools.trainlog.model.HeartRateRrInterval
import com.labfytools.trainlog.model.HeartRateSample
import com.labfytools.trainlog.model.LatestExerciseMax
import com.labfytools.trainlog.model.NewExerciseProfile
import com.labfytools.trainlog.model.PreparedSession
@ -486,6 +490,21 @@ class TrainlogRepository(
data class Rejected(val reason: String) : SleepDiaryImportResult
}
sealed interface StartHeartRateCaptureResult {
data class Started(val captureId: String) : StartHeartRateCaptureResult
data object Invalid : StartHeartRateCaptureResult
data object Conflict : StartHeartRateCaptureResult
data object DatabaseError : StartHeartRateCaptureResult
}
sealed interface HeartRateMutationResult {
data class Applied(val sequence: Long? = null) : HeartRateMutationResult
data object Invalid : HeartRateMutationResult
data object NotFound : HeartRateMutationResult
data object Conflict : HeartRateMutationResult
data object DatabaseError : HeartRateMutationResult
}
private fun sleepId(prefix: String): String = "${prefix}_${UUID.randomUUID()}"
private fun sleepIdentity(value: String, prefix: String): Boolean =
@ -1139,6 +1158,391 @@ class TrainlogRepository(
)
}
private fun heartRateContextIdValid(kind: HeartRateContextKind, value: String): Boolean =
when (kind) {
HeartRateContextKind.SESSION,
HeartRateContextKind.CARDIO ->
Regex("^se_[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$")
.matches(value)
HeartRateContextKind.SLEEP -> sleepIdentity(value, "sl")
}
private fun heartRateContextExists(
db: SQLiteDatabase,
kind: HeartRateContextKind,
contextId: String,
): Boolean =
when (kind) {
HeartRateContextKind.SESSION ->
db.rawQuery(
"SELECT 1 FROM active_session_draft WHERE id=1 AND session_id=? " +
"AND session_type IN('training','max_test') LIMIT 1",
arrayOf(contextId),
).use { it.moveToFirst() }
HeartRateContextKind.CARDIO ->
db.rawQuery(
"SELECT 1 FROM active_session_draft WHERE id=1 AND session_id=? " +
"AND session_type='cardio' LIMIT 1",
arrayOf(contextId),
).use { it.moveToFirst() }
HeartRateContextKind.SLEEP ->
db.rawQuery(
"SELECT 1 FROM sleep_diary_entries WHERE entry_id=? AND deleted=0 LIMIT 1",
arrayOf(contextId),
).use { it.moveToFirst() }
}
fun startHeartRateCapture(
contextKind: HeartRateContextKind,
contextId: String,
startedAt: String,
sensorName: String? = null,
): StartHeartRateCaptureResult {
val normalizedSensor = sensorName?.trim()?.takeIf { it.isNotEmpty() }
if (
!heartRateContextIdValid(contextKind, contextId) ||
TrainlogTimestamp.parse(startedAt) == null ||
(normalizedSensor != null &&
normalizedSensor.toByteArray(StandardCharsets.UTF_8).size >
MAX_HEART_RATE_SENSOR_NAME_UTF8_BYTES)
) return StartHeartRateCaptureResult.Invalid
val db = database.writableDatabase
db.beginTransaction()
return try {
if (!heartRateContextExists(db, contextKind, contextId)) {
StartHeartRateCaptureResult.Invalid
} else if (
db.rawQuery(
"SELECT 1 FROM heart_rate_captures WHERE active_slot=1 LIMIT 1",
null,
).use { it.moveToFirst() }
) {
StartHeartRateCaptureResult.Conflict
} else {
val captureId = "hrc_${UUID.randomUUID()}"
db.execSQL(
"INSERT INTO heart_rate_captures(" +
"capture_id,context_kind,context_id,started_at,ended_at,active_slot," +
"active_exercise_entry_id,sensor_name,acknowledged_at) " +
"VALUES(?,?,?,?,NULL,1,NULL,?,NULL)",
arrayOf(captureId, contextKind.wireValue, contextId, startedAt, normalizedSensor),
)
db.setTransactionSuccessful()
StartHeartRateCaptureResult.Started(captureId)
}
} catch (_: SQLiteConstraintException) {
StartHeartRateCaptureResult.Conflict
} catch (_: android.database.sqlite.SQLiteException) {
StartHeartRateCaptureResult.DatabaseError
} finally {
db.endTransaction()
}
}
fun setHeartRateActiveExercise(
captureId: String,
exerciseEntryId: String?,
): HeartRateMutationResult {
if (
!sleepIdentity(captureId, "hrc") ||
(exerciseEntryId != null && !SESSION_ENTRY_ID_V4_PATTERN.matches(exerciseEntryId))
) return HeartRateMutationResult.Invalid
val db = database.writableDatabase
db.beginTransaction()
return try {
val context =
db.rawQuery(
"SELECT context_kind,context_id FROM heart_rate_captures " +
"WHERE capture_id=? AND active_slot=1",
arrayOf(captureId),
).use { cursor ->
if (!cursor.moveToFirst()) null else cursor.getString(0) to cursor.getString(1)
} ?: return HeartRateMutationResult.NotFound
if (context.first !in setOf(
HeartRateContextKind.SESSION.wireValue,
HeartRateContextKind.CARDIO.wireValue,
)
) {
HeartRateMutationResult.Invalid
} else if (
exerciseEntryId != null &&
!db.rawQuery(
"SELECT 1 FROM draft_session_exercises de " +
"JOIN active_session_draft d ON d.id=de.draft_id " +
"WHERE d.id=1 AND d.session_id=? AND de.entry_id=? LIMIT 1",
arrayOf(context.second, exerciseEntryId),
).use { it.moveToFirst() }
) {
HeartRateMutationResult.Invalid
} else {
val values = ContentValues().apply {
if (exerciseEntryId == null) putNull("active_exercise_entry_id")
else put("active_exercise_entry_id", exerciseEntryId)
}
if (
db.update(
"heart_rate_captures",
values,
"capture_id=? AND active_slot=1",
arrayOf(captureId),
) != 1
) HeartRateMutationResult.Conflict
else {
db.setTransactionSuccessful()
HeartRateMutationResult.Applied()
}
}
} catch (_: android.database.sqlite.SQLiteException) {
HeartRateMutationResult.DatabaseError
} finally {
db.endTransaction()
}
}
fun appendHeartRateSample(
captureId: String,
observedAt: String,
bpm: Int,
sensorContactDetected: Boolean? = null,
energyExpended: Int? = null,
rrIntervals: List<HeartRateRrInterval> = emptyList(),
): HeartRateMutationResult {
val observed = TrainlogTimestamp.parse(observedAt)
if (
!sleepIdentity(captureId, "hrc") ||
observed == null ||
bpm !in 0..65535 ||
(energyExpended != null && energyExpended !in 0..65535) ||
rrIntervals.size > MAX_HEART_RATE_RR_PER_SAMPLE ||
rrIntervals.withIndex().any { (index, rr) ->
rr.index != index || rr.value1024 !in 0..65535
}
) return HeartRateMutationResult.Invalid
val db = database.writableDatabase
db.beginTransaction()
return try {
val capture =
db.rawQuery(
"SELECT started_at,active_exercise_entry_id FROM heart_rate_captures " +
"WHERE capture_id=? AND active_slot=1",
arrayOf(captureId),
).use { cursor ->
if (!cursor.moveToFirst()) null
else cursor.getString(0) to
if (cursor.isNull(1)) null else cursor.getString(1)
} ?: return HeartRateMutationResult.NotFound
val started = TrainlogTimestamp.parse(capture.first)
?: return HeartRateMutationResult.DatabaseError
if (observed < started) return HeartRateMutationResult.Invalid
val sequence =
db.rawQuery(
"SELECT COALESCE(MAX(sequence),-1)+1 FROM heart_rate_samples WHERE capture_id=?",
arrayOf(captureId),
).use { cursor ->
check(cursor.moveToFirst())
cursor.getLong(0)
}
if (sequence >= MAX_HEART_RATE_SAMPLES_PER_CAPTURE.toLong()) {
return HeartRateMutationResult.Conflict
}
db.execSQL(
"INSERT INTO heart_rate_samples(" +
"capture_id,sequence,observed_at,bpm,exercise_entry_id," +
"sensor_contact_detected,energy_expended) VALUES(?,?,?,?,?,?,?)",
arrayOf<Any?>(
captureId,
sequence,
observedAt,
bpm,
capture.second,
sensorContactDetected?.let { if (it) 1 else 0 },
energyExpended,
),
)
rrIntervals.forEach { rr ->
db.execSQL(
"INSERT INTO heart_rate_rr_intervals(" +
"capture_id,sample_sequence,rr_index,value_1024) VALUES(?,?,?,?)",
arrayOf<Any>(captureId, sequence, rr.index, rr.value1024),
)
}
db.setTransactionSuccessful()
HeartRateMutationResult.Applied(sequence)
} catch (_: SQLiteConstraintException) {
HeartRateMutationResult.Conflict
} catch (_: android.database.sqlite.SQLiteException) {
HeartRateMutationResult.DatabaseError
} finally {
db.endTransaction()
}
}
fun stopHeartRateCapture(
captureId: String,
endedAt: String,
): HeartRateMutationResult {
val ended = TrainlogTimestamp.parse(endedAt)
if (!sleepIdentity(captureId, "hrc") || ended == null)
return HeartRateMutationResult.Invalid
val db = database.writableDatabase
db.beginTransaction()
return try {
val startedText =
db.rawQuery(
"SELECT started_at FROM heart_rate_captures WHERE capture_id=? AND active_slot=1",
arrayOf(captureId),
).use { cursor ->
if (!cursor.moveToFirst()) null else cursor.getString(0)
} ?: return HeartRateMutationResult.NotFound
val started: TrainlogTimestampKey =
TrainlogTimestamp.parse(startedText)
?: return HeartRateMutationResult.DatabaseError
if (ended < started) return HeartRateMutationResult.Invalid
val values = ContentValues().apply {
put("ended_at", endedAt)
putNull("active_slot")
putNull("active_exercise_entry_id")
}
if (
db.update(
"heart_rate_captures",
values,
"capture_id=? AND active_slot=1",
arrayOf(captureId),
) != 1
) HeartRateMutationResult.Conflict
else {
db.setTransactionSuccessful()
HeartRateMutationResult.Applied()
}
} catch (_: android.database.sqlite.SQLiteException) {
HeartRateMutationResult.DatabaseError
} finally {
db.endTransaction()
}
}
fun activeHeartRateCapture(): HeartRateCapture? {
val db = database.readableDatabase
return db.rawQuery(
"SELECT capture_id,context_kind,context_id,started_at,ended_at," +
"active_exercise_entry_id,sensor_name FROM heart_rate_captures " +
"WHERE active_slot=1 LIMIT 1",
null,
).use { cursor ->
if (!cursor.moveToFirst()) null
else HeartRateCapture(
captureId = cursor.getString(0),
contextKind = HeartRateContextKind.fromWire(cursor.getString(1)),
contextId = cursor.getString(2),
startedAt = cursor.getString(3),
endedAt = if (cursor.isNull(4)) null else cursor.getString(4),
activeExerciseEntryId = if (cursor.isNull(5)) null else cursor.getString(5),
sensorName = if (cursor.isNull(6)) null else cursor.getString(6),
)
}
}
internal fun buildHeartRateV1Json(): String {
val db = database.readableDatabase
val captures = JSONArray()
db.rawQuery(
"SELECT c.capture_id,c.context_kind,c.context_id,c.started_at,c.ended_at,c.sensor_name " +
"FROM heart_rate_captures c WHERE c.ended_at IS NOT NULL " +
"AND c.acknowledged_at IS NULL AND NOT EXISTS(" +
"SELECT 1 FROM heart_rate_generation_captures m " +
"JOIN sync_generations g ON g.generation_id=m.generation_id " +
"WHERE m.capture_id=c.capture_id AND " +
"g.status IN('captured','published','waiting_acknowledgement')) " +
"ORDER BY c.ended_at,c.capture_id LIMIT ?",
arrayOf(MAX_HEART_RATE_CAPTURES_PER_GENERATION.toString()),
).use { cursor ->
while (cursor.moveToNext()) {
val captureId = cursor.getString(0)
val samples = JSONArray()
val rrBySequence = mutableMapOf<Long, JSONArray>()
db.rawQuery(
"SELECT sample_sequence,rr_index,value_1024 FROM heart_rate_rr_intervals " +
"WHERE capture_id=? ORDER BY sample_sequence,rr_index",
arrayOf(captureId),
).use { rrCursor ->
var currentSequence: Long? = null
var expectedIndex = 0
while (rrCursor.moveToNext()) {
val sequence = rrCursor.getLong(0)
if (sequence != currentSequence) {
currentSequence = sequence
expectedIndex = 0
}
check(rrCursor.getInt(1) == expectedIndex++)
rrBySequence.getOrPut(sequence) { JSONArray() }.put(rrCursor.getInt(2))
}
}
db.rawQuery(
"SELECT sequence,observed_at,bpm,exercise_entry_id," +
"sensor_contact_detected,energy_expended FROM heart_rate_samples " +
"WHERE capture_id=? ORDER BY sequence",
arrayOf(captureId),
).use { sampleCursor ->
var count = 0
while (sampleCursor.moveToNext()) {
check(++count <= MAX_HEART_RATE_SAMPLES_PER_CAPTURE)
val sequence = sampleCursor.getLong(0)
val rr = rrBySequence.remove(sequence) ?: JSONArray()
samples.put(
JSONObject()
.put("sequence", sequence)
.put("observed_at", sampleCursor.getString(1))
.put("bpm", sampleCursor.getInt(2))
.put(
"exercise_entry_id",
if (sampleCursor.isNull(3)) JSONObject.NULL
else sampleCursor.getString(3),
)
.put(
"sensor_contact_detected",
if (sampleCursor.isNull(4)) JSONObject.NULL
else sampleCursor.getInt(4) != 0,
)
.put(
"energy_expended",
if (sampleCursor.isNull(5)) JSONObject.NULL
else sampleCursor.getInt(5),
)
.put("rr_intervals_1024", rr),
)
}
}
check(rrBySequence.isEmpty())
captures.put(
JSONObject()
.put("capture_id", captureId)
.put("context_kind", cursor.getString(1))
.put("context_id", cursor.getString(2))
.put("started_at", cursor.getString(3))
.put("ended_at", cursor.getString(4))
.put(
"sensor_name",
if (cursor.isNull(5)) JSONObject.NULL else cursor.getString(5),
)
.put("samples", samples),
)
}
}
return JSONObject()
.put("format", "trainlog-heart-rate")
.put("version", 1)
.put("generated_at", OffsetDateTime.now().toString())
.put("captures", captures)
.toString()
}
/** Create a transactionally consistent SQLite snapshot without exposing WAL files. */
internal fun backupDatabaseTo(destination: File) {
require(!destination.exists()) { "Backup snapshot destination already exists." }
@ -1185,6 +1589,7 @@ class TrainlogRepository(
artifacts["causal-deletions"] = buildCausalDeletionExportV1Json()
artifacts["program-executions"] = buildProgramExecutionsV1Json()
artifacts["sleep-diary"] = buildSleepDiaryV1Json()
artifacts["heart-rate"] = buildHeartRateV1Json()
artifacts
}
@ -9554,6 +9959,10 @@ private val SESSION_ENTRY_ID_V4_PATTERN = Regex(
"^sxe_[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$",
)
private const val MAX_AI_DRAFTS = 256
private const val MAX_HEART_RATE_CAPTURES_PER_GENERATION = 256
private const val MAX_HEART_RATE_SAMPLES_PER_CAPTURE = 200000
private const val MAX_HEART_RATE_RR_PER_SAMPLE = 64
private const val MAX_HEART_RATE_SENSOR_NAME_UTF8_BYTES = 160
private const val MAX_AI_DRAFT_ENTRIES = 64
private const val MAX_AI_TARGET_SETS = 99
private const val MAX_AI_TARGET_REPS = 999
@ -9572,7 +9981,7 @@ private class TrainlogDatabaseHelper(
appContext,
databaseName,
null,
26,
27,
) {
override fun onConfigure(
db: SQLiteDatabase,
@ -9626,6 +10035,7 @@ private class TrainlogDatabaseHelper(
createSyncedProgramTables(db)
createProgramExecutionProvenance(db)
createSleepDiaryTables(db)
createHeartRateTables(db)
seedEquipment(db)
}
@ -9839,6 +10249,14 @@ private class TrainlogDatabaseHelper(
createSleepDiaryTables(db)
version = 26
}
if (version < 27 && newVersion >= 27) {
/* WHY: heart-rate samples are Android-owned measured field data.
* CONTRACT: v27 adds only cardio captures/samples and generation
* publication state. INVARIANT: no workout or sleep row is
* rewritten and no historical timestamp is invented. */
createHeartRateTables(db)
version = 27
}
if (version != newVersion) {
error(
@ -9977,6 +10395,71 @@ private class TrainlogDatabaseHelper(
PRIMARY KEY(generation_id,entry_id))""".trimIndent())
}
private fun createHeartRateTables(db: SQLiteDatabase) {
db.execSQL(
"""CREATE TABLE IF NOT EXISTS heart_rate_captures(
capture_id TEXT PRIMARY KEY,
context_kind TEXT NOT NULL CHECK(context_kind IN('session','cardio','sleep')),
context_id TEXT NOT NULL,
started_at TEXT NOT NULL,
ended_at TEXT,
active_slot INTEGER UNIQUE CHECK(active_slot IS NULL OR active_slot=1),
active_exercise_entry_id TEXT,
sensor_name TEXT,
acknowledged_at TEXT,
CHECK(capture_id GLOB 'hrc_*'),
CHECK((context_kind IN('session','cardio') AND context_id GLOB 'se_*') OR
(context_kind='sleep' AND context_id GLOB 'sl_*')),
CHECK(active_exercise_entry_id IS NULL OR active_exercise_entry_id GLOB 'sxe_*'),
CHECK(sensor_name IS NULL OR length(CAST(sensor_name AS BLOB))<=160)
)""".trimIndent(),
)
db.execSQL(
"""CREATE TABLE IF NOT EXISTS heart_rate_samples(
capture_id TEXT NOT NULL
REFERENCES heart_rate_captures(capture_id) ON DELETE CASCADE,
sequence INTEGER NOT NULL CHECK(sequence>=0),
observed_at TEXT NOT NULL,
bpm INTEGER NOT NULL CHECK(bpm BETWEEN 0 AND 65535),
exercise_entry_id TEXT,
sensor_contact_detected INTEGER
CHECK(sensor_contact_detected IS NULL OR sensor_contact_detected IN(0,1)),
energy_expended INTEGER
CHECK(energy_expended IS NULL OR energy_expended BETWEEN 0 AND 65535),
PRIMARY KEY(capture_id,sequence),
CHECK(exercise_entry_id IS NULL OR exercise_entry_id GLOB 'sxe_*')
)""".trimIndent(),
)
db.execSQL(
"""CREATE TABLE IF NOT EXISTS heart_rate_rr_intervals(
capture_id TEXT NOT NULL,
sample_sequence INTEGER NOT NULL,
rr_index INTEGER NOT NULL CHECK(rr_index BETWEEN 0 AND 63),
value_1024 INTEGER NOT NULL CHECK(value_1024 BETWEEN 0 AND 65535),
PRIMARY KEY(capture_id,sample_sequence,rr_index),
FOREIGN KEY(capture_id,sample_sequence)
REFERENCES heart_rate_samples(capture_id,sequence) ON DELETE CASCADE
)""".trimIndent(),
)
db.execSQL(
"""CREATE TABLE IF NOT EXISTS heart_rate_generation_captures(
generation_id TEXT NOT NULL
REFERENCES sync_generations(generation_id) ON DELETE RESTRICT,
capture_id TEXT NOT NULL
REFERENCES heart_rate_captures(capture_id) ON DELETE RESTRICT,
PRIMARY KEY(generation_id,capture_id)
)""".trimIndent(),
)
db.execSQL(
"CREATE INDEX IF NOT EXISTS heart_rate_samples_time " +
"ON heart_rate_samples(observed_at,capture_id,sequence)",
)
db.execSQL(
"CREATE INDEX IF NOT EXISTS heart_rate_captures_context " +
"ON heart_rate_captures(context_kind,context_id,started_at)",
)
}
private fun createSyncDataLifecycleTables(db: SQLiteDatabase) {
fun addColumn(table: String, name: String, declaration: String) {
if (!tableHasColumn(db, table, name)) {

View file

@ -0,0 +1,46 @@
/*
* Android HeartRateModels model.
*
* Android owns sensor acquisition. Desktop/Web only consume synchronized
* measured facts and never connect to the heart-rate sensor.
*/
package com.labfytools.trainlog.model
enum class HeartRateContextKind(val wireValue: String) {
SESSION("session"),
CARDIO("cardio"),
SLEEP("sleep");
companion object {
fun fromWire(value: String): HeartRateContextKind =
entries.firstOrNull { it.wireValue == value }
?: error("Unknown heart-rate context: $value")
}
}
data class HeartRateRrInterval(
val index: Int,
/** Raw Bluetooth Heart Rate Service unit: 1/1024 second. */
val value1024: Int,
)
data class HeartRateSample(
val sequence: Long,
val observedAt: String,
val bpm: Int,
val exerciseEntryId: String? = null,
val sensorContactDetected: Boolean? = null,
val energyExpended: Int? = null,
val rrIntervals: List<HeartRateRrInterval> = emptyList(),
)
data class HeartRateCapture(
val captureId: String,
val contextKind: HeartRateContextKind,
val contextId: String,
val startedAt: String,
val endedAt: String?,
val activeExerciseEntryId: String?,
val sensorName: String?,
val samples: List<HeartRateSample> = emptyList(),
)

View file

@ -77,6 +77,7 @@ fun AndroidAppShell(
AppRoute.BodyMeasurements -> R.string.route_body_measurements; AppRoute.LatestMaxima -> R.string.route_latest_maxima
AppRoute.SleepDiary -> R.string.nav_sleep
AppRoute.Sync -> R.string.nav_sync; AppRoute.Settings -> R.string.nav_settings
AppRoute.HeartRateDiagnostics -> R.string.route_heart_rate_diagnostics
})
val drawerState = rememberDrawerState(DrawerValue.Closed)
val scope = rememberCoroutineScope()

View file

@ -39,6 +39,7 @@ sealed interface AppRoute {
data object LatestMaxima : AppRoute { override val section = AppSection.EXERCISES }
data object Sync : AppRoute { override val section = AppSection.SYNC }
data object Settings : AppRoute { override val section = AppSection.SETTINGS }
data object HeartRateDiagnostics : AppRoute { override val section = AppSection.SETTINGS }
}
fun AppSection.rootRoute(): AppRoute = when (this) {

View file

@ -0,0 +1,180 @@
package com.labfytools.trainlog.ui
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import com.labfytools.trainlog.R
import com.labfytools.trainlog.data.HeartRateBleDiagnostics
import com.labfytools.trainlog.data.HeartRateDiagnosticPhase
import com.labfytools.trainlog.data.HeartRateDiagnosticSnapshot
import com.labfytools.trainlog.data.hasHeartRateBleRuntimePermissions
import com.labfytools.trainlog.data.heartRateBleRuntimePermissions
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
@Composable
fun HeartRateDiagnosticsScreen() {
val context = LocalContext.current
val strings = localizedContext()
val colors = LocalTrainlogColors.current
var snapshot by remember { mutableStateOf(HeartRateDiagnosticSnapshot()) }
var pendingExpandedScan by remember { mutableStateOf(false) }
var permissionDenied by remember { mutableStateOf(false) }
val diagnostics =
remember(context) {
HeartRateBleDiagnostics(context) { next -> snapshot = next }
}
DisposableEffect(diagnostics) {
onDispose { diagnostics.close() }
}
val permissionLauncher =
rememberLauncherForActivityResult(
ActivityResultContracts.RequestMultiplePermissions(),
) { results ->
if (heartRateBleRuntimePermissions().all { results[it] == true }) {
permissionDenied = false
diagnostics.startScan(pendingExpandedScan)
} else {
permissionDenied = true
}
}
fun scan(expanded: Boolean) {
pendingExpandedScan = expanded
if (hasHeartRateBleRuntimePermissions(context)) {
permissionDenied = false
diagnostics.startScan(expanded)
} else {
permissionLauncher.launch(heartRateBleRuntimePermissions())
}
}
fun phaseLabel(): String =
strings.getString(
when (snapshot.phase) {
HeartRateDiagnosticPhase.IDLE -> R.string.hr_diag_idle
HeartRateDiagnosticPhase.SCANNING -> R.string.hr_diag_scanning
HeartRateDiagnosticPhase.CONNECTING -> R.string.hr_diag_connecting
HeartRateDiagnosticPhase.DISCOVERING -> R.string.hr_diag_discovering
HeartRateDiagnosticPhase.SUBSCRIBING -> R.string.hr_diag_subscribing
HeartRateDiagnosticPhase.RECEIVING -> R.string.hr_diag_receiving
HeartRateDiagnosticPhase.DISCONNECTED -> R.string.hr_diag_disconnected
HeartRateDiagnosticPhase.ERROR -> R.string.hr_diag_error
},
)
TrainlogScreen(strings.getString(R.string.route_heart_rate_diagnostics)) {
TrainlogFrame(strings.getString(R.string.hr_diag_status)) {
TrainlogInfo(
phaseLabel(),
when (snapshot.phase) {
HeartRateDiagnosticPhase.RECEIVING -> colors.success
HeartRateDiagnosticPhase.ERROR -> colors.error
HeartRateDiagnosticPhase.CONNECTING,
HeartRateDiagnosticPhase.DISCOVERING,
HeartRateDiagnosticPhase.SUBSCRIBING,
HeartRateDiagnosticPhase.SCANNING -> colors.warning
else -> colors.muted
},
)
if (permissionDenied) {
TrainlogInfo(strings.getString(R.string.hr_diag_permission_denied), colors.error)
}
snapshot.diagnostic?.let {
TrainlogInfo(strings.getString(R.string.hr_diag_technical, it), colors.muted)
}
TrainlogInfo(
strings.getString(
R.string.hr_diag_services,
if (snapshot.serviceFound) strings.getString(R.string.yes) else strings.getString(R.string.no),
if (snapshot.measurementFound) strings.getString(R.string.yes) else strings.getString(R.string.no),
if (snapshot.batteryServiceFound) strings.getString(R.string.yes) else strings.getString(R.string.no),
),
)
snapshot.latestMeasurement?.let { measurement ->
TrainlogInfo(
strings.getString(R.string.hr_diag_bpm, measurement.bpm),
colors.success,
)
TrainlogInfo(
strings.getString(
R.string.hr_diag_measurement_meta,
measurement.rrIntervals1024.size,
measurement.sensorContactDetected?.let { if (it) strings.getString(R.string.yes) else strings.getString(R.string.no) } ?: "—",
measurement.energyExpended?.toString() ?: "—",
),
colors.muted,
)
}
TrainlogInfo(
strings.getString(R.string.hr_diag_notifications, snapshot.notificationCount),
colors.muted,
)
}
TrainlogFrame(strings.getString(R.string.hr_diag_scan_section)) {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
TrainlogButton(
strings.getString(R.string.hr_diag_scan),
{ scan(false) },
Modifier.weight(1f),
enabled = snapshot.phase != HeartRateDiagnosticPhase.SCANNING,
)
TrainlogButton(
strings.getString(R.string.hr_diag_scan_all),
{ scan(true) },
Modifier.weight(1f),
enabled = snapshot.phase != HeartRateDiagnosticPhase.SCANNING,
)
}
TrainlogInfo(strings.getString(R.string.hr_diag_scan_description), colors.muted)
}
TrainlogFrame(
strings.getString(R.string.hr_diag_devices),
active = snapshot.devices.isNotEmpty(),
) {
if (snapshot.devices.isEmpty()) {
TrainlogInfo(strings.getString(R.string.hr_diag_no_devices), colors.muted)
}
snapshot.devices.forEach { device ->
val title =
device.name?.takeIf { it.isNotBlank() }
?: strings.getString(R.string.hr_diag_unnamed_device)
val advertised =
strings.getString(
if (device.advertisesHeartRate) {
R.string.hr_diag_hrs_advertised
} else {
R.string.hr_diag_hrs_not_advertised
},
)
TrainlogAction(
title,
strings.getString(
R.string.hr_diag_device_description,
device.address,
device.rssi,
advertised,
),
{ diagnostics.connect(device.address) },
)
}
}
}
}

View file

@ -472,7 +472,12 @@ private fun catalogCountsText(context: android.content.Context, imported: Int, r
).joinToString(", ")
@Composable
fun SettingsScreen(repository: TrainlogRepository, inbox: SyncCatalogInbox, onCatalogChanged: () -> Unit) {
fun SettingsScreen(
repository: TrainlogRepository,
inbox: SyncCatalogInbox,
onHeartRateDiagnostics: () -> Unit,
onCatalogChanged: () -> Unit,
) {
val colors = LocalTrainlogColors.current
val context = LocalContext.current
val language = LocalLanguagePresentation.current
@ -533,6 +538,13 @@ fun SettingsScreen(repository: TrainlogRepository, inbox: SyncCatalogInbox, onCa
)
}
}
TrainlogFrame(strings.getString(R.string.settings_cardio_section)) {
TrainlogAction(
strings.getString(R.string.settings_cardio_diagnostics),
strings.getString(R.string.settings_cardio_diagnostics_description),
onHeartRateDiagnostics,
)
}
TrainlogFrame(strings.getString(R.string.settings_exchange_folder)) {
TrainlogInfo(strings.getString(if (authorized) R.string.settings_folder_granted else R.string.settings_folder_required), if (authorized) colors.success else colors.warning)
TrainlogAction(strings.getString(if (authorized) R.string.settings_open_access else R.string.settings_authorize), strings.getString(R.string.settings_authorize_description), { launcher.launch(directStoragePermissionIntent(context)) })

View file

@ -195,7 +195,8 @@ fun TrainlogApp(repository: TrainlogRepository, exporter: SyncExporter, inbox: S
AppRoute.SleepDiary -> SleepDiaryScreen(repository)
AppRoute.LatestMaxima -> LatestMaximaScreen(repository)
AppRoute.Sync -> SyncScreen(repository, inbox, exporter, requestOutbox, { exportSnapshot(); catalogRevision++ }, { back() })
AppRoute.Settings -> SettingsScreen(repository, inbox) { exportSnapshot(); catalogRevision++ }
AppRoute.Settings -> SettingsScreen(repository, inbox, { open(AppRoute.HeartRateDiagnostics) }) { exportSnapshot(); catalogRevision++ }
AppRoute.HeartRateDiagnostics -> HeartRateDiagnosticsScreen()
}}
}

View file

@ -480,4 +480,35 @@
<string name="sleep_unit">Unit</string>
<string name="sleep_deactivate">Deactivate</string>
<string name="sleep_edit">Edit</string>
<string name="yes">Yes</string>
<string name="no">No</string>
<string name="route_heart_rate_diagnostics">BLE heart-rate diagnostics</string>
<string name="settings_cardio_section">Cardio</string>
<string name="settings_cardio_diagnostics">Heart-rate sensor diagnostics</string>
<string name="settings_cardio_diagnostics_description">Find and verify a Bluetooth Heart Rate Service sensor.</string>
<string name="hr_diag_status">Sensor status</string>
<string name="hr_diag_idle">Ready</string>
<string name="hr_diag_scanning">Scanning…</string>
<string name="hr_diag_connecting">Connecting…</string>
<string name="hr_diag_discovering">Discovering services…</string>
<string name="hr_diag_subscribing">Enabling measurements…</string>
<string name="hr_diag_receiving">Measurements received</string>
<string name="hr_diag_disconnected">Disconnected</string>
<string name="hr_diag_error">BLE error</string>
<string name="hr_diag_permission_denied">Bluetooth permission denied.</string>
<string name="hr_diag_technical">Diagnostic: %1$s</string>
<string name="hr_diag_services">Heart Rate Service: %1$s · measurement: %2$s · battery: %3$s</string>
<string name="hr_diag_bpm">%1$d BPM</string>
<string name="hr_diag_measurement_meta">%1$d RR · contact %2$s · energy %3$s</string>
<string name="hr_diag_notifications">Notifications received: %1$d</string>
<string name="hr_diag_scan_section">BLE scan</string>
<string name="hr_diag_scan">Find heart-rate</string>
<string name="hr_diag_scan_all">Expanded scan</string>
<string name="hr_diag_scan_description">Heart-rate scan lasts at most 10 seconds. Expanded scan is for sensors that do not advertise their service in BLE advertisements.</string>
<string name="hr_diag_devices">BLE devices</string>
<string name="hr_diag_no_devices">No device found yet.</string>
<string name="hr_diag_unnamed_device">Unnamed device</string>
<string name="hr_diag_hrs_advertised">Heart Rate advertised</string>
<string name="hr_diag_hrs_not_advertised">Heart Rate not advertised</string>
<string name="hr_diag_device_description">%1$s · %2$d dBm · %3$s</string>
</resources>

View file

@ -490,4 +490,35 @@
<string name="sleep_unit">Unité</string>
<string name="sleep_deactivate">Désactiver</string>
<string name="sleep_edit">Modifier</string>
<string name="yes">Oui</string>
<string name="no">Non</string>
<string name="route_heart_rate_diagnostics">Diagnostic cardio BLE</string>
<string name="settings_cardio_section">Cardio</string>
<string name="settings_cardio_diagnostics">Diagnostic du capteur cardio</string>
<string name="settings_cardio_diagnostics_description">Rechercher et vérifier un capteur Bluetooth Heart Rate Service.</string>
<string name="hr_diag_status">État du capteur</string>
<string name="hr_diag_idle">Prêt</string>
<string name="hr_diag_scanning">Recherche en cours…</string>
<string name="hr_diag_connecting">Connexion…</string>
<string name="hr_diag_discovering">Découverte des services…</string>
<string name="hr_diag_subscribing">Activation des mesures…</string>
<string name="hr_diag_receiving">Mesures reçues</string>
<string name="hr_diag_disconnected">Déconnecté</string>
<string name="hr_diag_error">Erreur BLE</string>
<string name="hr_diag_permission_denied">Autorisation Bluetooth refusée.</string>
<string name="hr_diag_technical">Diagnostic : %1$s</string>
<string name="hr_diag_services">Heart Rate Service : %1$s · mesure : %2$s · batterie : %3$s</string>
<string name="hr_diag_bpm">%1$d BPM</string>
<string name="hr_diag_measurement_meta">%1$d RR · contact %2$s · énergie %3$s</string>
<string name="hr_diag_notifications">Notifications reçues : %1$d</string>
<string name="hr_diag_scan_section">Recherche BLE</string>
<string name="hr_diag_scan">Rechercher cardio</string>
<string name="hr_diag_scan_all">Recherche élargie</string>
<string name="hr_diag_scan_description">La recherche cardio dure au maximum 10 secondes. La recherche élargie sert aux capteurs qui n’annoncent pas leur service dans la publicité BLE.</string>
<string name="hr_diag_devices">Périphériques BLE</string>
<string name="hr_diag_no_devices">Aucun périphérique trouvé pour l’instant.</string>
<string name="hr_diag_unnamed_device">Périphérique sans nom</string>
<string name="hr_diag_hrs_advertised">Heart Rate annoncé</string>
<string name="hr_diag_hrs_not_advertised">Heart Rate non annoncé</string>
<string name="hr_diag_device_description">%1$s · %2$d dBm · %3$s</string>
</resources>

View file

@ -66,7 +66,7 @@ class AndroidV10PlanningMigrationTest {
TrainlogRepository(context, name).useForTest { it.listSessions() }
SQLiteDatabase.openDatabase(path.path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
assertEquals(26, scalar(db, "PRAGMA user_version"))
assertEquals(27, scalar(db, "PRAGMA user_version"))
for (table in listOf("session_exercises", "draft_session_exercises")) {
assertEquals(6, scalar(db, "SELECT COUNT(*) FROM pragma_table_info('$table') WHERE name IN ('load_mode','rest_seconds','target_sets','target_reps','target_duration_seconds','target_weight_kg')"))
assertEquals(0, scalar(db, "SELECT COUNT(*) FROM $table WHERE load_mode<>'none' OR rest_seconds<>0 OR target_sets IS NOT NULL OR target_reps IS NOT NULL OR target_duration_seconds IS NOT NULL OR target_weight_kg IS NOT NULL"))

View file

@ -69,7 +69,7 @@ class AndroidV11ExerciseAliasMigrationTest {
migrated.close()
}
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
assertEquals(26, scalar(db, "PRAGMA user_version"))
assertEquals(27, scalar(db, "PRAGMA user_version"))
/* v13 intentionally adds the frozen machine identities and
* exercise-owned metadata; all non-exercise facts stay exact. */
val after = snapshot(db).filterKeys { it !in setOf("exercise_aliases", "exercise_profile_state", "exercise_profile_revisions") }

View file

@ -78,7 +78,7 @@ class AndroidV16AiSessionDraftMigrationTest {
migrated.close()
}
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
assertEquals(26, scalar(db, "PRAGMA user_version"))
assertEquals(27, scalar(db, "PRAGMA user_version"))
assertEquals(before, listOf(
scalar(db, "SELECT COUNT(*) FROM sessions"),
scalar(db, "SELECT COUNT(*) FROM performed_sets"),

View file

@ -69,7 +69,7 @@ class AndroidV25ProgramExecutionMigrationTest {
reopened.close()
}
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
assertEquals(26, scalar(db, "PRAGMA user_version"))
assertEquals(27, scalar(db, "PRAGMA user_version"))
assertEquals(0, scalar(db, "SELECT COUNT(*) FROM sessions WHERE source_program_id IS NOT NULL"))
assertEquals("ok", text(db, "PRAGMA integrity_check"))
db.rawQuery("PRAGMA foreign_key_check", null).use { cursor ->

View file

@ -35,7 +35,7 @@ class AndroidV26SleepMigrationTest {
context.getDatabasePath(name).path,
null,
SQLiteDatabase.OPEN_READONLY,
).use { assertEquals(26, it.version) }
).use { assertEquals(27, it.version) }
} finally {
repository.close()
context.deleteDatabase(name)

View file

@ -0,0 +1,54 @@
package com.labfytools.trainlog.data
import android.content.Context
import android.database.sqlite.SQLiteDatabase
import androidx.test.core.app.ApplicationProvider
import java.util.UUID
import org.json.JSONObject
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [35])
class AndroidV27HeartRateMigrationTest {
private val context: Context = ApplicationProvider.getApplicationContext()
@Test
fun v26AddsEmptyHeartRateDomainAndReopens() {
val name = "heart-rate-v26-${UUID.randomUUID()}.db"
TrainlogRepository(context, name).also {
JSONObject(it.buildHeartRateV1Json())
it.close()
}
SQLiteDatabase.openDatabase(
context.getDatabasePath(name).path,
null,
SQLiteDatabase.OPEN_READWRITE,
).use { db ->
db.execSQL("DROP TABLE heart_rate_generation_captures")
db.execSQL("DROP TABLE heart_rate_rr_intervals")
db.execSQL("DROP TABLE heart_rate_samples")
db.execSQL("DROP TABLE heart_rate_captures")
db.version = 26
}
var repository = TrainlogRepository(context, name)
try {
assertEquals(0, JSONObject(repository.buildHeartRateV1Json())
.getJSONArray("captures").length())
repository.close()
repository = TrainlogRepository(context, name)
SQLiteDatabase.openDatabase(
context.getDatabasePath(name).path,
null,
SQLiteDatabase.OPEN_READONLY,
).use { assertEquals(27, it.version) }
} finally {
repository.close()
context.deleteDatabase(name)
}
}
}

View file

@ -328,7 +328,7 @@ class BodyZonesTest {
SQLiteDatabase.openDatabase(context.getDatabasePath(name).path, null,
SQLiteDatabase.OPEN_READONLY).use { db ->
db.rawQuery("PRAGMA user_version", null).use { cursor ->
assertTrue(cursor.moveToFirst()); assertEquals(26, cursor.getInt(0))
assertTrue(cursor.moveToFirst()); assertEquals(27, cursor.getInt(0))
}
db.rawQuery("SELECT id FROM exercises", null).use { cursor ->
assertTrue(cursor.moveToFirst()); assertEquals(42L, cursor.getLong(0))

View file

@ -43,7 +43,7 @@ class BridgeBackupMigrationTest {
null,
android.database.sqlite.SQLiteDatabase.OPEN_READONLY,
)
.use { assertEquals(26, it.version) }
.use { assertEquals(27, it.version) }
} finally {
repository.close()
context.deleteDatabase(name)

View file

@ -0,0 +1,95 @@
package com.labfytools.trainlog.data
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class HeartRateMeasurementParserTest {
private fun parsed(vararg bytes: Int): ParsedHeartRateMeasurement {
val result =
HeartRateMeasurementParser.parse(
ByteArray(bytes.size) { index -> bytes[index].toByte() },
)
return (result as HeartRateMeasurementParseResult.Parsed).measurement
}
private fun invalid(vararg bytes: Int): HeartRateMeasurementParseResult.Invalid =
HeartRateMeasurementParser.parse(
ByteArray(bytes.size) { index -> bytes[index].toByte() },
) as HeartRateMeasurementParseResult.Invalid
@Test
fun parsesEightBitBpm() {
val value = parsed(0x00, 72)
assertEquals(72, value.bpm)
assertNull(value.sensorContactDetected)
assertNull(value.energyExpended)
assertTrue(value.rrIntervals1024.isEmpty())
}
@Test
fun parsesSixteenBitBpmLittleEndian() {
assertEquals(300, parsed(0x01, 0x2c, 0x01).bpm)
assertEquals(65535, parsed(0x01, 0xff, 0xff).bpm)
}
@Test
fun contactIsNullableAndOnlyMeaningfulWhenSupported() {
assertNull(parsed(0x02, 80).sensorContactDetected)
assertEquals(false, parsed(0x04, 80).sensorContactDetected)
assertEquals(true, parsed(0x06, 80).sensorContactDetected)
}
@Test
fun parsesEnergyExpended() {
val value = parsed(0x08, 90, 0x34, 0x12)
assertEquals(0x1234, value.energyExpended)
}
@Test
fun parsesOneAndMultipleRawRrIntervals() {
assertEquals(listOf(1024), parsed(0x10, 60, 0x00, 0x04).rrIntervals1024)
assertEquals(
listOf(1024, 1000, 65535),
parsed(0x10, 60, 0x00, 0x04, 0xe8, 0x03, 0xff, 0xff).rrIntervals1024,
)
}
@Test
fun parsesCombinedKnownFlagsInWireOrder() {
val value =
parsed(
0x1f,
0x96,
0x00,
0x07,
0x00,
0xf0,
0x03,
0x00,
0x04,
)
assertEquals(150, value.bpm)
assertEquals(true, value.sensorContactDetected)
assertEquals(7, value.energyExpended)
assertEquals(listOf(1008, 1024), value.rrIntervals1024)
}
@Test
fun rejectsTruncatedAndIncoherentPayloads() {
assertEquals("missing flags", invalid().reason)
assertEquals("truncated 16-bit BPM", invalid(0x01, 0x2c).reason)
assertEquals("truncated energy-expended value", invalid(0x08, 70, 0x01).reason)
assertEquals("RR flag has no interval", invalid(0x10, 70).reason)
assertEquals("truncated RR interval", invalid(0x10, 70, 0x01).reason)
assertEquals("unexpected trailing bytes", invalid(0x00, 70, 0x01).reason)
assertEquals("reserved flags are set", invalid(0x20, 70).reason)
}
@Test
fun preservesBoundaryBpmValues() {
assertEquals(0, parsed(0x00, 0).bpm)
assertEquals(255, parsed(0x00, 255).bpm)
}
}

View file

@ -0,0 +1,96 @@
package com.labfytools.trainlog.data
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import com.labfytools.trainlog.model.HeartRateContextKind
import com.labfytools.trainlog.model.HeartRateRrInterval
import com.labfytools.trainlog.model.SleepDiaryDraft
import java.util.UUID
import org.json.JSONObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [35])
class HeartRateRepositoryTest {
private val context: Context = ApplicationProvider.getApplicationContext()
@Test
fun sleepCaptureStoresRawSamplesAndExportsOnlyAfterStop() {
val name = "heart-rate-${UUID.randomUUID()}.db"
val repository = TrainlogRepository(context, name)
try {
val created =
repository.saveSleepDiary(
SleepDiaryDraft(
nightStartDate = "2026-09-22",
nightEndDate = "2026-09-23",
createdAt = "2026-09-22T22:00:00+02:00",
updatedAt = "2026-09-22T22:00:00+02:00",
sleepQuality = null,
wakeQuality = null,
dayForm = null,
treatmentAndNotes = "",
events = emptyList(),
),
) as TrainlogRepository.SaveSleepDiaryResult.Saved
val started =
repository.startHeartRateCapture(
HeartRateContextKind.SLEEP,
created.entryId,
"2026-09-22T22:30:00+02:00",
"Synthetic HR sensor",
) as TrainlogRepository.StartHeartRateCaptureResult.Started
assertEquals(started.captureId, repository.activeHeartRateCapture()?.captureId)
val sample =
repository.appendHeartRateSample(
captureId = started.captureId,
observedAt = "2026-09-22T22:30:01+02:00",
bpm = 61,
sensorContactDetected = true,
energyExpended = 7,
rrIntervals = listOf(
HeartRateRrInterval(0, 1024),
HeartRateRrInterval(1, 1000),
),
) as TrainlogRepository.HeartRateMutationResult.Applied
assertEquals(0L, sample.sequence)
assertEquals(
0,
JSONObject(repository.buildHeartRateV1Json()).getJSONArray("captures").length(),
)
assertTrue(
repository.stopHeartRateCapture(
started.captureId,
"2026-09-23T06:30:00+02:00",
) is TrainlogRepository.HeartRateMutationResult.Applied,
)
assertNull(repository.activeHeartRateCapture())
val root = JSONObject(repository.buildHeartRateV1Json())
assertEquals("trainlog-heart-rate", root.getString("format"))
assertEquals(1, root.getInt("version"))
val capture = root.getJSONArray("captures").getJSONObject(0)
assertEquals(created.entryId, capture.getString("context_id"))
assertEquals("sleep", capture.getString("context_kind"))
assertEquals("Synthetic HR sensor", capture.getString("sensor_name"))
val stored = capture.getJSONArray("samples").getJSONObject(0)
assertEquals(61, stored.getInt("bpm"))
assertTrue(stored.getBoolean("sensor_contact_detected"))
assertEquals(7, stored.getInt("energy_expended"))
assertEquals(1024, stored.getJSONArray("rr_intervals_1024").getInt(0))
assertEquals(1000, stored.getJSONArray("rr_intervals_1024").getInt(1))
} finally {
repository.close()
context.deleteDatabase(name)
}
}
}

View file

@ -360,7 +360,7 @@ class SessionPreparationExchangeTest {
repository = TrainlogRepository(context, name)
assertEquals(1, repository.listPreparedSessions().size)
repository.close()
assertEquals("26", scalarText(name, "PRAGMA user_version"))
assertEquals("27", scalarText(name, "PRAGMA user_version"))
assertEquals(
"0",
scalarText(name, "SELECT COUNT(*) FROM session_preparation_withdrawals"),

View file

@ -3,11 +3,14 @@ package com.labfytools.trainlog.data
import android.content.Context
import android.database.sqlite.SQLiteDatabase
import androidx.test.core.app.ApplicationProvider
import com.labfytools.trainlog.model.HeartRateContextKind
import com.labfytools.trainlog.model.HeartRateRrInterval
import com.labfytools.trainlog.model.NewExerciseProfile
import com.labfytools.trainlog.model.RecordingMode
import com.labfytools.trainlog.model.SessionDraft
import com.labfytools.trainlog.model.SessionExerciseDraft
import com.labfytools.trainlog.model.SessionSetDraft
import com.labfytools.trainlog.model.SleepDiaryDraft
import com.labfytools.trainlog.model.TrackingMode
import java.io.File
import java.nio.file.Files
@ -506,7 +509,7 @@ class SyncGenerationServiceTest {
.use { db ->
db.rawQuery("PRAGMA user_version", null).use {
assertTrue(it.moveToFirst())
assertEquals(26, it.getInt(0))
assertEquals(27, it.getInt(0))
}
db.rawQuery("SELECT COUNT(*) FROM exercises", null).use {
assertTrue(it.moveToFirst())
@ -855,7 +858,54 @@ class SyncGenerationServiceTest {
)
)!!
.groupValues[1]
val sleepEntry =
source.saveSleepDiary(
SleepDiaryDraft(
nightStartDate = "2026-09-22",
nightEndDate = "2026-09-23",
createdAt = "2026-09-22T22:00:00+02:00",
updatedAt = "2026-09-22T22:00:00+02:00",
sleepQuality = null,
wakeQuality = null,
dayForm = null,
treatmentAndNotes = "",
events = emptyList(),
),
) as TrainlogRepository.SaveSleepDiaryResult.Saved
val heartCapture =
source.startHeartRateCapture(
HeartRateContextKind.SLEEP,
sleepEntry.entryId,
"2026-09-22T22:30:00+02:00",
"Synthetic HR",
) as TrainlogRepository.StartHeartRateCaptureResult.Started
assertTrue(
source.appendHeartRateSample(
heartCapture.captureId,
"2026-09-22T22:30:01+02:00",
61,
true,
7,
listOf(HeartRateRrInterval(0, 1024)),
) is TrainlogRepository.HeartRateMutationResult.Applied,
)
assertTrue(
source.stopHeartRateCapture(
heartCapture.captureId,
"2026-09-22T22:31:00+02:00",
) is TrainlogRepository.HeartRateMutationResult.Applied,
)
val androidToDesktop = sourceService.capture(root, desktopPeer)
assertEquals(
1,
JSONObject(
java.io.File(androidToDesktop.stagingDirectory, "heart-rate-v1.json")
.readText(),
)
.getJSONArray("captures")
.length(),
)
val androidPublished =
sourceService.publish(
androidToDesktop,
@ -872,10 +922,31 @@ class SyncGenerationServiceTest {
"--ack-output",
androidAckFile.absolutePath,
)
val androidAck = JSONObject(androidAckFile.readText())
assertEquals(
"desktop ACK diagnostic: ${androidAck.getString("diagnostic")}",
"consumed",
androidAck.getString("result"),
)
assertEquals(
"acknowledged",
sourceService.acceptAcknowledgement(androidAckFile.readBytes()),
)
assertEquals(
0,
JSONObject(source.buildHeartRateV1Json()).getJSONArray("captures").length(),
)
assertEquals(
"1|1|1",
run(
"sqlite3",
desktopConsumerDb.absolutePath,
"SELECT (SELECT count(*) FROM heart_rate_captures) || '|' || " +
"(SELECT count(*) FROM heart_rate_samples) || '|' || " +
"(SELECT count(*) FROM heart_rate_rr_intervals);",
)
.trim(),
)
val androidSessionId = source.listSessions().single().sessionId
assertEquals(
"1",

View file

@ -2260,7 +2260,7 @@ class TrainlogRepositoryDraftTest {
).use { db ->
db.rawQuery("PRAGMA user_version;", null).use { cursor ->
assertTrue(cursor.moveToFirst())
assertEquals(26, cursor.getInt(0))
assertEquals(27, cursor.getInt(0))
}
db.rawQuery(
"SELECT eq.equipment_id, ps.reps, ps.weight_kg FROM session_exercises se " +
@ -2387,7 +2387,7 @@ class TrainlogRepositoryDraftTest {
).use { db ->
db.rawQuery("PRAGMA user_version;", null).use { cursor ->
assertTrue(cursor.moveToFirst())
assertEquals(26, cursor.getInt(0))
assertEquals(27, cursor.getInt(0))
}
db.rawQuery("SELECT weight_kg FROM performed_sets WHERE id = 1;", null).use { cursor ->
assertTrue(cursor.moveToFirst())

View file

@ -81,7 +81,7 @@ The rollout packager now keeps the legacy request daemon and one-shot helper in
the same relocatable, hashed bundle as the Web generation coordinator, avoiding
mixed-checkout user-service execution during the controlled rollout.
Snapshot date: **2026-09-22**.
Snapshot date: **2026-09-23**.
`TRAINLOG_CODE_READABILITY_V1` normalizes the recent generation-MTP,
generation/ACK, Android-backup, Web Dashboard serialization, tests, doubles and
@ -112,8 +112,8 @@ parallel implementation of its rules.
| Boundary | Current state |
|---|---|
| Frozen project exchange | `TRAINLOG_FORMAT_V1=PASS/FROZEN` |
| Desktop SQLite | schema v29; Sleep Diary V1 is additive in v29 |
| Android SQLite | schema v26; Sleep Diary V1 is additive in v26 |
| Desktop SQLite | schema v30; synchronized Heart Rate V1 storage is additive in v30 |
| Android SQLite | schema v27; Android-owned Heart Rate V1 capture storage is additive in v27 |
| Mobile snapshot | V3 active; V1/V2 readable legacy inputs; explicit V4 codec staged, not selected by transport |
| Desktop terminal backend | Notcurses only |
| Trainlog product version | `0.1.5` development, synchronized across Android and desktop; latest stable release: `v0.1.4` |
@ -147,7 +147,9 @@ parallel implementation of its rules.
| Private full-generation rollout | `TRAINLOG_SYNC_FINALIZATION_AND_ROLLOUT_V1=PASS` (authenticated Bluetooth primary transport with direct MTP recovery on one authorized phone) |
| Local Web | `TRAINLOG_WEB_V1=CONTRACT_FROZEN / IMPLEMENTATION_STARTED` |
| Web Exercises V1 | `TRAINLOG_WEB_EXERCISES_V1=PASS` |
| Current operational cursor | `TRAINLOG_CARDIO_V1_NEXT_CONTRACT`: heart-rate sensor ingestion starts in 0.1.5 and remains distinct from the frozen Bluetooth synchronization transport |
| Cardio persistence/sync | `TRAINLOG_CARDIO_PERSISTENCE_SYNC_V1=PASS`: Android v27 + desktop v30, strict optional `trainlog-heart-rate` V1 Android→desktop companion, generation relation and correlated ACK |
| Cardio BLE diagnostics | `TRAINLOG_CARDIO_BLE_DIAGNOSTICS_V1=SOFTWARE_PASS / HARDWARE_VALIDATION_PENDING`: strict `0x2A37` parser, bounded HRS/expanded BLE scan, GATT service discovery and notification subscription are installed on Android 0.1.5 vc38; filtered real-device scan observed no advertising HRS device on 2026-09-23 |
| Current operational cursor | `TRAINLOG_CARDIO_BLE_HARDWARE_VALIDATION_V1`: identify the real armband, confirm `0x180D/0x2A37`, notification cadence and optional RR/contact/energy fields before persistent acquisition |
| Complete synchronization gap contract | Frozen dependency contract; operational USB/Drive slices required by v0.1.2 are delivered |
| Isolated synchronization test environment | `TRAINLOG_SYNC_TEST_ENV_V1=PASS/FROZEN` |
| Synchronization characterization | `TRAINLOG_SYNC_CHARACTERIZATION_V1=PASS/FROZEN` |

View file

@ -1,5 +1,25 @@
# Desktop database
## Schema v30: synchronized Heart Rate V1
Schema v30 additively owns Android-recorded heart-rate captures after
full-generation synchronization. `heart_rate_captures` stores one immutable
stopped capture with its stable `hrc_` identity, owner kind (`session` or
`sleep`), stable owner identity, exact start/end timestamps, optional sensor
name, and import time.
`heart_rate_samples` stores the received timestamp, measured BPM, optional
exercise `entry_id` snapshot, optional sensor-contact state, and optional
cumulative energy value. `heart_rate_rr_intervals` preserves zero or more raw
Heart Rate Service RR values in their native 1/1024-second units. Desktop never
creates these measurements and never connects to the sensor.
The v29→v30 migration is additive and creates no cardio row. Heart-rate data
travels Android→desktop through the separate optional `trainlog-heart-rate`
V1 generation companion. Exact replay is idempotent; a stable capture/sample
identity with different measured content is rejected. Existing workout,
Sleep Diary, and frozen mobile formats are unchanged.
## Schema v29: Sleep Diary V1
Schema v29 additively owns `sleep_diary_entries`, immutable
@ -9,14 +29,14 @@ one night and its associated day. Point events have an offset-bearing
their start. The 18:00-to-18:00 agenda is only a projection and is never stored
as cells.
The same unreleased migration owns `sleep_medications`, immutable
The same stable v0.1.4 migration owns `sleep_medications`, immutable
`sleep_medication_revisions`, and revision-owned `sleep_medication_intakes`.
Catalog defaults assist capture but do not own historical facts: every intake
stores the displayed medication name and effective optional positive dose/unit
snapshot. Renaming or deactivating a catalog item therefore cannot rewrite a
past entry. Development databases that had already opened the earlier v29
draft are completed idempotently on open; `user_version` remains 29 and no
existing row is rewritten.
draft are completed idempotently on open before the additive v30 migration;
no existing sleep row is rewritten.
Mutations advance `current_revision_id` only when the caller supplies the
current parent. Logical deletion advances the revision and retains the

412
docs/design/cardio_v1.md Normal file
View file

@ -0,0 +1,412 @@
# Cardio V1
Status: `TRAINLOG_CARDIO_V1=CONTRACT_FROZEN / IMPLEMENTATION_STARTED`
This document is the implementation contract for Trainlog 0.1.5 cardio,
training-timeline correlation, and Android one-tap sleep capture. It does not
declare the implementation complete.
## Ownership and hard boundaries
Android is the only heart-rate sensor client. It owns BLE discovery,
connection, reconnection, Heart Rate Measurement parsing, local persistence,
capture lifecycle, and the live BPM presentation.
Desktop, TUI and Web never scan, pair with, or connect directly to a
heart-rate sensor. They consume only measurements already recorded by Android
and synchronized through Trainlog.
Heart-rate BLE/GATT and Trainlog Bluetooth synchronization are independent:
```text
heart-rate sensor -- BLE/GATT --> Android
Android <-------- Trainlog full-generation --------> desktop
over
Bluetooth Classic RFCOMM or MTP recovery
```
The cardio implementation must not reuse the frozen Bluetooth Classic service
UUID, framing, peer identity, connection state, or transport protocol.
## Standard sensor target
Cardio V1 targets the Bluetooth LE Heart Rate Service:
- Heart Rate Service: `0x180D`;
- Heart Rate Measurement: `0x2A37`.
The parser must support the standard 8-bit and 16-bit BPM forms, optional
sensor-contact state, optional cumulative energy expended, and zero or more RR
intervals. RR intervals are preserved as raw integer units of 1/1024 second.
Battery Service may be observed as optional device metadata but is not required
to retain a heart-rate sample.
Malformed notifications are rejected. Trainlog never manufactures missing BPM
or RR values.
## Global Android heart-rate indicator
The Android application shell exposes one compact indicator at the top right:
```text
♥ 82 BPM
```
Only the heart and BPM are shown. Sensor name, address, and technical state are
not shown in the global header.
Heart colour describes connection freshness only:
- grey: known sensor unavailable/disconnected;
- orange: connecting, reconnecting, or the last measurement is stale;
- green: connected and receiving fresh valid measurements.
Colour never indicates a medical zone or whether the BPM is high or low.
When no fresh measurement exists, the value is `-- BPM`. A stale previous
number must never remain visible as if it were current.
With a fresh BPM the heart uses a restrained pulse animation derived from
`60 / bpm`. This is only a visual representation of the current measured
rate; it does not claim knowledge of the exact instant of each contraction.
## Capture identities and contexts
One capture has a stable `hrc_<uuid-v4>` identity and exactly one semantic
context:
- `session`: a normal training or MAX session identified by stable
`se_<uuid-v4>`;
- `cardio`: a dedicated cardio session identified by stable
`se_<uuid-v4>`;
- `sleep`: one Sleep Diary entry identified by stable `sl_<uuid-v4>`.
Context is never inferred from a title, clock date, exercise name, or display
position.
A workout/cardio sample may additionally snapshot the stable active occurrence
`sxe_<uuid-v4>` at the instant the sample is received.
## Raw measurement contract
Each received measurement stores at minimum:
- `capture_id`;
- monotonically increasing `sequence` within that capture;
- offset-bearing `observed_at` reception timestamp;
- integer `bpm`.
When actually supplied by the sensor it may also store:
- sensor-contact state;
- cumulative energy-expended value;
- zero or more RR intervals in 1/1024-second units.
For workout/cardio captures it may store the active `entry_id` snapshot.
Raw storage preserves signal gaps. It does not interpolate, smooth, estimate,
diagnose, infer sleep stages, infer a return to sleep, or synthesize missing
measurements.
## Android durability
Android SQLite owns authoritative field capture. An active capture remains
durable across application navigation and ordinary process/lifecycle
transitions supported by Android. Long-running acquisition uses a dedicated
foreground service distinct from synchronization.
Cardio V1 permits one active heart-rate capture at a time.
Sensor disconnect does not delete or close a capture. Reconnection resumes
future measurements and leaves the missing interval as a real gap.
Explicit stop closes the capture with its actual `ended_at`.
## Synchronization contract
Recorded cardio is synchronized Android -> desktop as the separate optional
full-generation companion:
```text
logical_name = heart-rate
format = trainlog-heart-rate
version = 1
filename = heart-rate-v1.json
```
Desktop persists and presents these measurements but is not a sensor producer.
Normal publication contains completed/stopped, not-yet-acknowledged captures.
An active capture is retained locally and is not emitted as a mutable
full-generation snapshot.
Identity is idempotent:
- capture key: `capture_id`;
- sample key: `capture_id + sequence`;
- RR key: `capture_id + sequence + rr_index`.
Exact replay is a no-op. Reusing an identity with different measured content
is rejected.
The exact captured `capture_id -> generation_id` relation is durable. Only a
correlated consumed ACK marks those captures acknowledged. A rejected
generation does not acknowledge them. Acknowledged captures remain in Android
history but are excluded from ordinary future publication.
The companion does not alter `TRAINLOG_FORMAT_V1`, mobile-export V3/V4,
Sleep Diary V1, generation/ACK framing, or Bluetooth Classic sync V1.
## Training timeline correlation
A training session must expose real time boundaries, never reconstructed
timings.
Session start/end use the existing stable session identity and canonical
`started_at` / `ended_at` lifecycle when those values are already the
authoritative timestamps.
Each session occurrence has an explicit execution lifecycle:
```text
not_started -> in_progress -> completed
```
Starting an exercise records its actual start timestamp against
`session_id + entry_id`. Ending it records its actual end timestamp.
Only one occurrence may be active at a time. Starting another occurrence while
one remains active requires an explicit resolution rather than silently
inventing an end.
Samples received while an occurrence is active snapshot that `entry_id`.
Samples between occurrences remain valid session samples with no fabricated
exercise ownership and represent rest/transition.
Ending a session while an occurrence is active requires an explicit UI action
to close the occurrence at the validated finalization instant.
The synchronized history must permit factual curve markers for:
- session start;
- exercise start;
- exercise end;
- session end.
Future set-level `set_start` / `set_end` events may extend this model, but
Cardio V1 must not infer them from reps, duration targets, or form edits.
## Dedicated cardio session type
Trainlog 0.1.5 adds a real third session type:
```text
training
max_test
cardio
```
Every existing boundary that assumes only `training|max_test` must be audited.
A frozen artifact may not be silently widened. If an existing wire codec
cannot safely express `cardio`, add a separately versioned compatible
extension while retaining legacy readers.
A cardio session consists of cardio exercises and optional phases. Initial
exercise families include treadmill, bicycle, elliptical, rower, stepper,
walking, running, and custom cardio.
A phase may express a type, target, duration, and exit condition. Required
phase concepts include warm-up, work, recovery, and cool-down.
The motor must be able to represent fixed duration, reaching a BPM zone,
recovering below a threshold, and bounded combinations of time and
heart-rate conditions.
Machine speed, resistance, incline, and distance may be recorded, but BPM is
the primary guidance signal.
## BPM-guidance motor
Cardio V1 guidance outputs are:
```text
ACCÉLÈRE
MAINTIENS
RALENTIS
```
Guidance is based only on fresh measurements and the phase target.
Hysteresis and a minimum persistence interval prevent oscillation around a
boundary. One isolated sample must not cause rapid instruction changes.
When the signal becomes stale, guidance is suspended. The global heart becomes
orange and BPM becomes unavailable rather than continuing from an old value.
## Calibration cardio exercise
The first dedicated cardio exercise is `Calibration cardio`.
Its purpose is to establish a personal measured reference for later guided
exercise. Trainlog records an **observed peak BPM**, not an automatically
asserted physiological maximum.
The protocol is progressive:
```text
warm-up -> easy stage -> progressive stages -> user-chosen highest tolerated
effort for that day -> stop effort -> recovery observation
```
A manual stop is always immediately available. The motor never attempts to
beat the previous observed peak automatically.
Each calibration is immutable/versioned and retains at minimum:
- stable `calibration_id`;
- protocol version;
- performance timestamp;
- associated heart-rate capture;
- observed peak BPM;
- effort-end timestamp;
- recovery timeline.
Recovery projections may expose +1, +2 and +3 minute facts while the complete
raw curve remains authoritative.
A later calibration creates a new reference. It never rewrites an older
calibration or a prior guided session.
Every guided cardio session snapshots the exact `calibration_id` and target
thresholds used at that time.
Measured, user-configured, and calculated/estimated values remain explicitly
distinguishable.
## Android Sleep entry point
Android adds a first-class `Sommeil` menu entry backed by the existing Sleep
Diary domain. It must not create a parallel sleep database or incompatible
sleep truth.
Night-time interaction is deliberately minimal. The primary screen exposes:
```text
[ COUCHÉ ]
[ médicament 1 ] [ médicament 2 ]
[ médicament 3 ] [ + ]
[ RÉVEIL ]
[ LEVÉ ]
```
Buttons are large, visually consistent with Trainlog, and usable while only
partly awake.
### Couché
One tap records the actual `bed_time` timestamp in the correct stable Sleep
Diary entry. If heart-rate acquisition is available, the nocturnal capture is
linked directly to that `sleep entry_id`.
### Médicaments
Active configured medications are one-tap actions. A tap stores the actual
intake timestamp plus stable medication identity and the effective
name/dose/unit snapshot. Full editing remains available later.
### Réveil
There is no `Rendormi` button.
One tap records one punctual night wake event. Nothing else is required before
the user tries to return to sleep.
Trainlog does not infer a certain return-to-sleep time from BPM or RR data.
An approximate interval may be completed later only if the user chooses to
provide it.
### Levé
One tap records the actual `final_get_up` timestamp, closes the night, and
stops the linked nocturnal heart-rate capture when one is active.
### Immediate undo and later correction
After Couché, medication, Réveil, or Levé, Android shows a short-lived action
such as:
```text
Réveil enregistré à 02:17 [ ANNULER ]
```
Undo is scoped to that exact action and must not roll back unrelated newer
state.
`Corriger la nuit` provides later editing of timestamps, medication
intakes, accidental wake points, forgotten events, and existing subjective
Sleep Diary fields.
The night-time rule is: one tap, one timestamp, then no further required
interaction.
## Sleep + cardio factual timeline
A nocturnal capture is owned by stable `sleep entry_id`, not only a date.
After synchronization, Analyse may superpose factual events and measurements:
- Couché;
- medication intakes;
- wake points;
- Levé;
- BPM;
- RR when supplied;
- real sensor disconnect/reconnect gaps.
Trainlog does not diagnose sleep, invent sleep stages, or claim a certain
return-to-sleep instant from heart rate alone.
## Web/Analyse projection
Web and desktop consume synchronized facts only.
Training views may show the BPM curve, session bounds, exercise intervals,
rest/transition gaps, and factual min/max/mean projections.
Cardio views may additionally show phase targets, chronological guidance,
calibration identity, and measured response versus target.
Sleep views may overlay Sleep Diary events with BPM/RR measurements.
Graphs are projections. Persistent measurements, identities, and timestamps
remain the source of truth.
## Performance bounds
One night can contain tens of thousands of samples. Storage and exchange must
use bounded artifacts, indexed temporal queries, transactional import, and
avoid per-sample N+1 behavior on analysis paths.
Trainlog does not arbitrarily downsample the authoritative raw capture merely
for transport convenience. Presentation may derive bounded projections while
retaining the recorded source data.
## Implementation order
1. audit/freeze this contract;
2. Android + desktop persistence and strict heart-rate companion exchange;
3. standard Heart Rate Measurement parser and real-sensor diagnostics;
4. Android foreground acquisition service and global ♥ BPM indicator;
5. training start/end and exercise start/end timeline;
6. Android one-tap Sleep UI and sleep-linked capture;
7. true `cardio` session type;
8. versioned Calibration cardio exercise;
9. BPM-guided phase motor;
10. Web factual visualization;
11. complete regression, migration, hardware, replay, and restart validation.
No stage permits Web/TUI sensor connection.

View file

@ -59,14 +59,17 @@ mobile snapshot or `TRAINLOG_FORMAT_V1` contracts.
Stable v0.1.4 closes the Analyse/Sleep Diary cycle and freezes the Bluetooth
Classic synchronization transport independently from future sensor work.
The next development cycle is 0.1.5 cardio. Android will own heart-rate sensor
discovery/acquisition and timestamped local capture; synchronized desktop/Web
surfaces will consume recorded measurements and will not connect directly to
the sensor. Heart-rate ingestion must not reuse or overload the frozen Trainlog
The 0.1.5 cycle owns cardio end to end. Android alone discovers and reads the
heart-rate sensor, exposes the global ♥ BPM state, timestamps training
session/exercise boundaries, provides one-tap Sleep capture, and records the
new dedicated cardio session type. Cardio starts with a versioned Calibration
exercise and later drives phase guidance from fresh BPM. Desktop/Web consume
only synchronized recorded measurements and never connect directly to the
sensor. Heart-rate ingestion must not reuse or overload the frozen Trainlog
Bluetooth synchronization transport contract.
```text
CURRENT_OPERATIONAL_CURSOR=TRAINLOG_CARDIO_V1_NEXT_CONTRACT
CURRENT_OPERATIONAL_CURSOR=TRAINLOG_CARDIO_BLE_HARDWARE_VALIDATION_V1
```
The focused `TRAINLOG_SYNC_CAUSAL_DELETE_V1_CLOSEOUT` is complete before this

View file

@ -1060,7 +1060,36 @@ format. It lists bounded durable desktop AI proposals and relevant execution
drafts as distinct kinds. Completed sessions remain outside this projection,
and finalized or causally deleted execution drafts are excluded.
## 19. Legacy active-protocol limitations
## 19. Heart-rate companion V1
`trainlog-heart-rate` V1 is an optional full-generation participant produced
only by Android. Its logical name is `heart-rate` and its immutable generation
file is `heart-rate-v1.json`. Desktop never produces sensor measurements and
never connects to the heart-rate device.
A normal Android generation includes only stopped captures that have not
already been acknowledged and are not already retained by an outstanding
captured/published/waiting-ACK generation. Capture, sample and RR identities
are respectively `capture_id`, `capture_id + sequence`, and
`capture_id + sequence + rr_index`.
Desktop schema v30 validates and imports the whole companion inside the
containing generation transaction. Exact replay is idempotent; reusing one
stable measurement identity with different content rejects the generation.
Android schema v27 records the exact `capture_id -> generation_id` relation
when the immutable generation is captured. Only a correlated `consumed` ACK
sets those captures' acknowledgement timestamps. Rejected generations do not
advance cardio acknowledgement. Acknowledged captures remain in Android
history but are omitted from normal later publication, preventing a full
night's samples from being retransmitted on every sync.
The companion changes neither the frozen Bluetooth Classic RFCOMM transport,
MTP recovery, manifest/ACK framing, Sleep Diary V1 nor mobile-export semantics.
Heart-rate BLE/GATT acquisition remains Android-only and is a separate
implementation layer.
## 20. Legacy active-protocol limitations
The Web end-to-end tranche adds an explicit opt-in application orchestrator.
`POST /api/v1/sync` admits one correlated run and `GET /api/v1/sync/status` is

View file

@ -1648,3 +1648,52 @@ checks passed, both outgoing desktop generations were acknowledged, and the
Android UI displayed completion. The private journal retains exact IDs and
hashes. Drive and `connectedDebugAndroidTest` were not run; the personal phone
was used only for non-destructive install, backup and manual sync validation.
## Cardio persistence/synchronization V1 validation
`TRAINLOG_CARDIO_PERSISTENCE_SYNC_V1=PASS` is covered without requiring a
physical heart-rate sensor. Android migration coverage advances schema v26 to
v27 and verifies durable raw BPM/contact/energy/RR capture; desktop migration
coverage advances v29 to v30 and verifies the additive cardio tables/indexes.
`heart_rate_exchange` validates the strict `trainlog-heart-rate` V1 envelope,
cardio/session/sleep ownership, timestamp bounds, raw RR preservation, exact
idempotent replay, and rejection of identity reuse with different measured
content. The Android full-generation regression captures a stopped sleep-owned
measurement, publishes it, consumes it through the production desktop
generation tool, accepts the correlated ACK, verifies one desktop
capture/sample/RR row, and proves the acknowledged Android capture is no longer
emitted.
The 2026-09-23 closeout passes the complete native Meson inventory:
**102/102 passed, 0 failed**. The complete Android JVM inventory reports
**258 tests: 253 passed, five skipped, zero failures/errors**. The five skips
remain optional historical/external fixture gates and are not Cardio V1
failures.
## Cardio BLE diagnostics V1 validation
`TRAINLOG_CARDIO_BLE_DIAGNOSTICS_V1=SOFTWARE_PASS / HARDWARE_VALIDATION_PENDING`
keeps BLE protocol parsing separate from Android GATT transport. Unit coverage
exercises 8-bit and 16-bit BPM, nullable sensor-contact semantics, cumulative
energy, one/multiple raw RR intervals, combined flags, boundary BPM values,
reserved flags, missing fields, truncated RR/energy values and unexpected
trailing bytes.
The Android diagnostic uses a 10-second Heart Rate Service-filtered scan with
an explicit expanded BLE fallback, then discovers `0x180D`, locates `0x2A37`,
checks optional Battery Service and enables the CCC notification descriptor.
It is screen-scoped and does not implement the later persistent foreground
acquisition owner.
On 2026-09-23 the diagnostic APK was installed non-destructively on the
authorized SM_G990B as Trainlog 0.1.5 versionCode 38 using the same Android
debug signing certificate as the existing private installation. Application
startup completed without SQLite/crash errors and the diagnostic route opened.
The first real 10-second HRS-filtered scan completed cleanly but found no
device advertising Heart Rate Service. Real armband identification, GATT HRS
confirmation and notification-field characterization therefore remain
`HARDWARE_VALIDATION_PENDING` and are not represented as PASS.
The complete Android JVM inventory after this tranche reports **266 tests:
261 passed, five skipped, zero failures/errors**, and `assembleDebug` succeeds.

View file

@ -0,0 +1,126 @@
#!/usr/bin/env python3
"""Heart Rate V1 companion validation and idempotent desktop import tests."""
import json
import sqlite3
import sys
import tempfile
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "tools"))
import heart_rate_exchange as heart_rate
CAPTURE = "hrc_11111111-1111-4111-8111-111111111111"
SESSION = "se_22222222-2222-4222-8222-222222222222"
ENTRY = "sxe_33333333-3333-4333-8333-333333333333"
def schema(db: sqlite3.Connection) -> None:
db.executescript(
"""
PRAGMA foreign_keys=ON;
CREATE TABLE heart_rate_captures(
capture_id TEXT PRIMARY KEY,context_kind TEXT,context_id TEXT,
started_at TEXT,ended_at TEXT,sensor_name TEXT,imported_at TEXT);
CREATE TABLE heart_rate_samples(
capture_id TEXT,sequence INTEGER,observed_at TEXT,bpm INTEGER,
exercise_entry_id TEXT,sensor_contact_detected INTEGER,
energy_expended INTEGER,PRIMARY KEY(capture_id,sequence),
FOREIGN KEY(capture_id) REFERENCES heart_rate_captures(capture_id));
CREATE TABLE heart_rate_rr_intervals(
capture_id TEXT,sample_sequence INTEGER,rr_index INTEGER,value_1024 INTEGER,
PRIMARY KEY(capture_id,sample_sequence,rr_index),
FOREIGN KEY(capture_id,sample_sequence)
REFERENCES heart_rate_samples(capture_id,sequence));
PRAGMA user_version=30;
"""
)
def document() -> dict:
return {
"format": "trainlog-heart-rate",
"version": 1,
"generated_at": "2026-09-22T19:00:00+02:00",
"captures": [{
"capture_id": CAPTURE,
"context_kind": "session",
"context_id": SESSION,
"started_at": "2026-09-22T18:00:00+02:00",
"ended_at": "2026-09-22T18:30:00+02:00",
"sensor_name": "Synthetic HR",
"samples": [{
"sequence": 0,
"observed_at": "2026-09-22T18:01:00+02:00",
"bpm": 92,
"exercise_entry_id": ENTRY,
"sensor_contact_detected": True,
"energy_expended": 12,
"rr_intervals_1024": [700, 710],
}],
}],
}
class HeartRateExchangeTest(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.db = sqlite3.connect(Path(self.temp.name) / "heart.sqlite")
schema(self.db)
def tearDown(self):
self.db.close()
self.temp.cleanup()
def test_import_exact_replay_and_raw_rr(self):
payload = heart_rate.validate(document())
self.assertEqual((1, 0), heart_rate.apply(self.db, payload))
self.assertEqual((0, 1), heart_rate.apply(self.db, payload))
self.assertEqual(
(92, ENTRY, 1, 12),
self.db.execute(
"SELECT bpm,exercise_entry_id,sensor_contact_detected,energy_expended "
"FROM heart_rate_samples"
).fetchone(),
)
self.assertEqual(
[(700,), (710,)],
self.db.execute(
"SELECT value_1024 FROM heart_rate_rr_intervals ORDER BY rr_index"
).fetchall(),
)
def test_same_identity_with_different_measurement_is_rejected(self):
payload = heart_rate.validate(document())
heart_rate.apply(self.db, payload)
changed = document()
changed["captures"][0]["samples"][0]["bpm"] = 93
with self.assertRaisesRegex(ValueError, "different content"):
heart_rate.apply(self.db, heart_rate.validate(changed))
def test_cardio_context_accepts_session_identity_and_exercise_marker(self):
cardio = document()
cardio["captures"][0]["context_kind"] = "cardio"
validated = heart_rate.validate(cardio)
self.assertEqual("cardio", validated["captures"][0]["context_kind"])
self.assertEqual(ENTRY, validated["captures"][0]["samples"][0]["exercise_entry_id"])
def test_rejects_sample_outside_capture_and_sleep_exercise_marker(self):
outside = document()
outside["captures"][0]["samples"][0]["observed_at"] = "2026-09-22T17:59:59+02:00"
with self.assertRaisesRegex(ValueError, "outside capture"):
heart_rate.validate(outside)
sleep = document()
capture = sleep["captures"][0]
capture["context_kind"] = "sleep"
capture["context_id"] = "sl_44444444-4444-4444-8444-444444444444"
with self.assertRaisesRegex(ValueError, "exercise context"):
heart_rate.validate(sleep)
if __name__ == "__main__":
unittest.main()

View file

@ -66,12 +66,13 @@ class DeploymentToolsTest(unittest.TestCase):
self.assertEqual(result.returncode, 0, result.stderr)
inventory = json.loads((output / "candidate-inventory.json").read_text())
self.assertEqual(inventory["product_version"], "0.1.5")
self.assertEqual(inventory["desktop_schema"], 29)
self.assertEqual(inventory["android_schema"], 26)
self.assertEqual(inventory["desktop_schema"], 30)
self.assertEqual(inventory["android_schema"], 27)
self.assertIn("session-preparations-v2", inventory["protocols"])
self.assertIn("programs-v1", inventory["protocols"])
self.assertIn("program-executions-v1", inventory["protocols"])
self.assertIn("sleep-diary-v1", inventory["protocols"])
self.assertIn("heart-rate-v1", inventory["protocols"])
self.assertIn("bluetooth-files-v1", inventory["protocols"])
self.assertTrue((output / "bin/trainlog").is_file())
self.assertTrue((output / "bin/trainlog-sync-once").is_file())

View file

@ -38,8 +38,8 @@ def digest(value) -> str:
def require_schema(db: sqlite3.Connection) -> None:
version = db.execute("PRAGMA user_version").fetchone()[0]
if version not in (20, 21, 22, 23, 24, 25, 26, 27, 28, 29):
raise CausalError(f"desktop schema v20-v29 required, found v{version}")
if version not in (20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30):
raise CausalError(f"desktop schema v20-v30 required, found v{version}")
def target_snapshot(db: sqlite3.Connection, kind: str, target: str):

View file

@ -182,8 +182,8 @@ def causal_payload(draft):
def require_schema(connection):
version = connection.execute("PRAGMA user_version").fetchone()[0]
if version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29):
raise LifecycleError(f"desktop schema v19-v29 required, found v{version}")
if version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30):
raise LifecycleError(f"desktop schema v19-v30 required, found v{version}")
def import_document(connection, document, own_transaction=True):

View file

@ -12,7 +12,7 @@ from pathlib import Path
from trainlog_sqlite import configure_connection
SUPPORTED_SCHEMA_VERSIONS = (15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29)
SUPPORTED_SCHEMA_VERSIONS = (15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30)
MEASUREMENT_FIELDS = (
"body_weight_kg", "neck_cm", "shoulders_cm", "chest_cm", "waist_cm",
"hips_cm", "left_arm_cm", "right_arm_cm", "left_forearm_cm",
@ -125,7 +125,7 @@ def build_export(connection, generated_at):
"""Build the raw export using only explicitly ordered SELECT statements."""
schema_version = connection.execute("PRAGMA user_version").fetchone()[0]
if schema_version not in SUPPORTED_SCHEMA_VERSIONS:
raise ValueError("schema desktop v15-v29 requis")
raise ValueError("schema desktop v15-v30 requis")
root = {
"format": "TRAINLOG_AI_EXPORT",

View file

@ -20,9 +20,9 @@ ID_PREFIX = "aid_"
def ensure_publication_state(connection):
"""Install the additive v18 cursor for direct syncd/tool invocations."""
owned_transaction = not connection.in_transaction
supported_versions = (18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29)
supported_versions = (18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30)
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
raise ValueError("schema desktop v18-v29 requis")
raise ValueError("schema desktop v18-v30 requis")
columns = {row[1] for row in connection.execute(
"PRAGMA table_info(ai_session_drafts)")}
if "published_at" not in columns:

View file

@ -28,9 +28,9 @@ def main():
args = parser.parse_args()
connection = connect_database(args.database)
try:
supported_versions = tuple(range(8, 30))
supported_versions = tuple(range(8, 31))
if connection.execute("PRAGMA user_version;").fetchone()[0] not in supported_versions:
raise ValueError("schema desktop v8 à v29 requis")
raise ValueError("schema desktop v8 à v30 requis")
known_equipment = load_supplied_equipment_ids(args.catalog)
known_equipment.update(row[0] for row in connection.execute(
"SELECT equipment_id FROM custom_equipment"))

View file

@ -24,9 +24,9 @@ def main(complete_causal_envelope=False):
connection = connect_database(args.database)
connection.row_factory = sqlite3.Row
try:
supported_versions = tuple(range(8, 30))
supported_versions = tuple(range(8, 31))
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
raise ValueError("schema desktop v8 à v29 requis")
raise ValueError("schema desktop v8 à v30 requis")
if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0] >= 20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='custom_equipment' AND deleted=1 LIMIT 1").fetchone():
raise ValueError("causal equipment protection requires the staged artifact")
equipment = [dict(row) for row in connection.execute(

View file

@ -17,8 +17,8 @@ def main():
con = connect_database(args.database)
try:
if con.execute("PRAGMA user_version").fetchone()[0] not in (
12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29):
raise ValueError("schema desktop v12-v29 requis")
12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30):
raise ValueError("schema desktop v12-v30 requis")
rows = con.execute(
"SELECT source_exercise_id,canonical_exercise_id FROM exercise_aliases "
"ORDER BY source_exercise_id COLLATE BINARY"

View file

@ -41,9 +41,9 @@ def main(complete_causal_envelope=False):
connection = connect_database(args.database)
connection.row_factory = sqlite3.Row
try:
supported_versions = tuple(range(11, 30))
supported_versions = tuple(range(11, 31))
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
raise ValueError("schema desktop v11-v16 requis")
raise ValueError("schema desktop v11-v30 requis")
if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0] >= 20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='body_zone_relation' AND deleted=1 LIMIT 1").fetchone():
raise ValueError("causal BODY ZONES protection requires the staged artifact")
exercises = []

View file

@ -25,7 +25,7 @@ def main():
con.row_factory = sqlite3.Row
try:
version = con.execute("PRAGMA user_version").fetchone()[0]
if version not in (17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29):
if version not in (17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30):
raise SystemExit(f"EXERCISE_PROFILE_STATE_EXPORT=FAIL schema={version}")
rows = con.execute("""
SELECT e.exercise_id,e.recording_mode,e.tracking_mode,e.data_fields,

View file

@ -71,7 +71,7 @@ def main():
# CONTRACT: v8 adds only desktop-local custom equipment. The PC
# catalogue artifact is unchanged, but it must read the current
# canonical desktop schema rather than accept a stale pre-v8 database.
if version not in range(8, 30):
if version not in range(8, 31):
raise SystemExit(
"PC_CATALOG_EXPORT=FAIL "
f"schema={version}"

View file

@ -83,12 +83,12 @@ def main(complete_causal_envelope=False):
con.row_factory = sqlite3.Row
try:
schema_version = con.execute("PRAGMA user_version").fetchone()[0]
if args.version == 4 and schema_version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29):
if args.version == 4 and schema_version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30):
raise ValueError("mobile V4 exige le schéma desktop v19 ou v20")
supported_versions = range(11, 30)
supported_versions = range(11, 31)
legacy_v2_schema = args.version == 2 and schema_version == 10
if schema_version not in supported_versions and not legacy_v2_schema:
raise ValueError("schema desktop v11-v16 requis (v10 accepté pour export V2 explicite)")
raise ValueError("schema desktop v11-v30 requis (v10 accepté pour export V2 explicite)")
if not complete_causal_envelope and schema_version >= 20 and con.execute(
"SELECT 1 FROM sync_causal_state WHERE deleted=1 LIMIT 1").fetchone():
raise ValueError("causal protection refuses a mobile snapshot that omits tombstones")

View file

@ -16,7 +16,7 @@ def nullable(row, index):
def export_programs(database: Path) -> dict:
with closing(connect_database(database)) as db:
if db.execute("PRAGMA user_version").fetchone()[0] not in (26, 27, 28, 29):
if db.execute("PRAGMA user_version").fetchone()[0] not in (26, 27, 28, 29, 30):
raise ValueError("desktop schema v26, v27, v28 or v29 required")
programs = []
for row in db.execute(

View file

@ -17,7 +17,7 @@ MAX_WITHDRAWALS = 128
def build_export(connection: sqlite3.Connection) -> dict:
supported_versions = (24, 25, 26, 27, 28, 29)
supported_versions = (24, 25, 26, 27, 28, 29, 30)
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
raise ValueError("desktop schema v24, v25, v26, v27, v28 or v29 required")
deliveries = []

View file

@ -10,9 +10,9 @@ def main(complete_causal_envelope=False):
parser=argparse.ArgumentParser(); parser.add_argument("output",type=Path); parser.add_argument("--database",type=Path,required=True); args=parser.parse_args()
db=connect_database(args.database)
try:
supported_versions = range(15, 30)
supported_versions = range(15, 31)
if db.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
raise ValueError("schema desktop v15-v29 requis")
raise ValueError("schema desktop v15-v30 requis")
if not complete_causal_envelope and db.execute("PRAGMA user_version").fetchone()[0] >= 20 and db.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='feedback' AND deleted=1 LIMIT 1").fetchone(): raise ValueError("causal feedback protection requires the staged artifact")
roots=db.execute("SELECT f.feedback_id,s.session_id,se.entry_id,e.exercise_id,f.observed_at FROM exercise_feedback f JOIN session_exercises se ON se.id=f.session_exercise_row_id JOIN sessions s ON s.id=se.session_row_id JOIN exercises e ON e.id=se.exercise_row_id").fetchall()
followups=db.execute("SELECT f.followup_id,s.session_id,f.observed_at FROM session_followups f JOIN sessions s ON s.id=f.session_row_id").fetchall()

View file

@ -0,0 +1,247 @@
#!/usr/bin/env python3
"""Validate and import the Android-owned Heart Rate V1 companion."""
import argparse
import json
import re
import sqlite3
from datetime import datetime
from pathlib import Path
from trainlog_sqlite import connect_database
FORMAT = "trainlog-heart-rate"
VERSION = 1
MAX_BYTES = 64 * 1024 * 1024
MAX_CAPTURES = 256
MAX_SAMPLES = 200_000
MAX_RR = 64
UUID4 = r"[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}"
CAPTURE_ID = re.compile(rf"^hrc_{UUID4}$")
SESSION_ID = re.compile(rf"^se_{UUID4}$")
SLEEP_ID = re.compile(rf"^sl_{UUID4}$")
ENTRY_ID = re.compile(rf"^sxe_{UUID4}$")
def fail(message: str) -> None:
raise ValueError(message)
def unique_object(pairs):
result = {}
for key, value in pairs:
if key in result:
fail("duplicate JSON field: " + key)
result[key] = value
return result
def timestamp(value: object) -> tuple[str, datetime]:
if not isinstance(value, str) or re.search(r"(?:Z|[+-][0-9]{2}:[0-9]{2})$", value) is None:
fail("invalid heart-rate timestamp")
try:
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError as error:
raise ValueError("invalid heart-rate timestamp") from error
if parsed.utcoffset() is None:
fail("heart-rate timestamp lacks offset")
return value, parsed
def validate(root: object) -> dict:
if not isinstance(root, dict) or set(root) != {
"format", "version", "generated_at", "captures"
}:
fail("invalid heart-rate envelope")
if root["format"] != FORMAT or type(root["version"]) is not int or root["version"] != VERSION:
fail("unsupported heart-rate format")
timestamp(root["generated_at"])
captures = root["captures"]
if not isinstance(captures, list) or len(captures) > MAX_CAPTURES:
fail("heart-rate capture bound exceeded")
seen_captures = set()
for capture in captures:
keys = {
"capture_id", "context_kind", "context_id", "started_at", "ended_at",
"sensor_name", "samples",
}
if not isinstance(capture, dict) or set(capture) != keys:
fail("invalid heart-rate capture")
capture_id = capture["capture_id"]
if not isinstance(capture_id, str) or not CAPTURE_ID.fullmatch(capture_id) or capture_id in seen_captures:
fail("invalid or duplicate heart-rate capture identity")
seen_captures.add(capture_id)
kind = capture["context_kind"]
context_id = capture["context_id"]
if kind in ("session", "cardio"):
if not isinstance(context_id, str) or not SESSION_ID.fullmatch(context_id):
fail("invalid heart-rate session context")
elif kind == "sleep":
if not isinstance(context_id, str) or not SLEEP_ID.fullmatch(context_id):
fail("invalid heart-rate sleep context")
else:
fail("invalid heart-rate context kind")
_, started = timestamp(capture["started_at"])
_, ended = timestamp(capture["ended_at"])
if ended < started:
fail("invalid heart-rate capture interval")
sensor_name = capture["sensor_name"]
if sensor_name is not None and (
not isinstance(sensor_name, str) or not sensor_name.strip() or len(sensor_name.encode()) > 160
):
fail("invalid heart-rate sensor name")
samples = capture["samples"]
if not isinstance(samples, list) or len(samples) > MAX_SAMPLES:
fail("heart-rate sample bound exceeded")
for expected, sample in enumerate(samples):
sample_keys = {
"sequence", "observed_at", "bpm", "exercise_entry_id",
"sensor_contact_detected", "energy_expended", "rr_intervals_1024",
}
if not isinstance(sample, dict) or set(sample) != sample_keys:
fail("invalid heart-rate sample")
if type(sample["sequence"]) is not int or sample["sequence"] != expected:
fail("invalid heart-rate sample sequence")
_, observed = timestamp(sample["observed_at"])
if observed < started or observed > ended:
fail("heart-rate sample outside capture")
if type(sample["bpm"]) is not int or not 0 <= sample["bpm"] <= 65535:
fail("invalid heart-rate bpm")
entry_id = sample["exercise_entry_id"]
if entry_id is not None and (
kind not in ("session", "cardio") or
not isinstance(entry_id, str) or
not ENTRY_ID.fullmatch(entry_id)
):
fail("invalid heart-rate exercise context")
contact = sample["sensor_contact_detected"]
if contact is not None and type(contact) is not bool:
fail("invalid sensor contact state")
energy = sample["energy_expended"]
if energy is not None and (type(energy) is not int or not 0 <= energy <= 65535):
fail("invalid heart-rate energy value")
rr = sample["rr_intervals_1024"]
if not isinstance(rr, list) or len(rr) > MAX_RR or any(
type(value) is not int or not 0 <= value <= 65535 for value in rr
):
fail("invalid heart-rate RR interval")
return root
def load(path: Path) -> dict:
if path.stat().st_size > MAX_BYTES:
fail("heart-rate artifact exceeds 64 MiB")
return validate(json.loads(path.read_text(encoding="utf-8"), object_pairs_hook=unique_object))
def _same_capture(db: sqlite3.Connection, capture: dict) -> bool:
row = db.execute(
"SELECT context_kind,context_id,started_at,ended_at,sensor_name "
"FROM heart_rate_captures WHERE capture_id=?",
(capture["capture_id"],),
).fetchone()
if row is None:
return False
if tuple(row) != (
capture["context_kind"],
capture["context_id"],
capture["started_at"],
capture["ended_at"],
capture["sensor_name"],
):
fail("heart-rate capture identity reused with different metadata")
stored_samples = db.execute(
"SELECT sequence,observed_at,bpm,exercise_entry_id,sensor_contact_detected,"
"energy_expended FROM heart_rate_samples WHERE capture_id=? ORDER BY sequence",
(capture["capture_id"],),
).fetchall()
if len(stored_samples) != len(capture["samples"]):
fail("heart-rate capture identity reused with different samples")
rr_by_sequence = {}
for sequence, rr_index, value in db.execute(
"SELECT sample_sequence,rr_index,value_1024 FROM heart_rate_rr_intervals "
"WHERE capture_id=? ORDER BY sample_sequence,rr_index",
(capture["capture_id"],),
):
values = rr_by_sequence.setdefault(sequence, [])
if rr_index != len(values):
fail("stored heart-rate RR sequence is not contiguous")
values.append(value)
for row, sample in zip(stored_samples, capture["samples"]):
contact = None if sample["sensor_contact_detected"] is None else int(sample["sensor_contact_detected"])
if tuple(row) != (
sample["sequence"], sample["observed_at"], sample["bpm"],
sample["exercise_entry_id"], contact, sample["energy_expended"],
):
fail("heart-rate sample identity reused with different content")
if rr_by_sequence.pop(sample["sequence"], []) != sample["rr_intervals_1024"]:
fail("heart-rate RR identity reused with different content")
if rr_by_sequence:
fail("heart-rate capture has orphan RR intervals")
return True
def apply(db: sqlite3.Connection, root: dict) -> tuple[int, int]:
applied = 0
unchanged = 0
for capture in root["captures"]:
if _same_capture(db, capture):
unchanged += 1
continue
db.execute(
"INSERT INTO heart_rate_captures("
"capture_id,context_kind,context_id,started_at,ended_at,sensor_name,imported_at"
") VALUES(?,?,?,?,?,?,?)",
(
capture["capture_id"], capture["context_kind"], capture["context_id"],
capture["started_at"], capture["ended_at"], capture["sensor_name"],
root["generated_at"],
),
)
db.executemany(
"INSERT INTO heart_rate_samples("
"capture_id,sequence,observed_at,bpm,exercise_entry_id,"
"sensor_contact_detected,energy_expended) VALUES(?,?,?,?,?,?,?)",
[
(
capture["capture_id"], sample["sequence"], sample["observed_at"],
sample["bpm"], sample["exercise_entry_id"],
None if sample["sensor_contact_detected"] is None
else int(sample["sensor_contact_detected"]),
sample["energy_expended"],
)
for sample in capture["samples"]
],
)
rr_rows = []
for sample in capture["samples"]:
rr_rows.extend(
(
capture["capture_id"], sample["sequence"], index, value
)
for index, value in enumerate(sample["rr_intervals_1024"])
)
db.executemany(
"INSERT INTO heart_rate_rr_intervals("
"capture_id,sample_sequence,rr_index,value_1024) VALUES(?,?,?,?)",
rr_rows,
)
applied += 1
return applied, unchanged
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("input", type=Path)
parser.add_argument("--database", required=True, type=Path)
args = parser.parse_args()
root = load(args.input)
with connect_database(args.database) as db:
if db.execute("PRAGMA user_version").fetchone()[0] != 30:
fail("desktop schema v30 required")
db.execute("BEGIN IMMEDIATE")
apply(db, root)
db.commit()
if __name__ == "__main__":
main()

View file

@ -193,9 +193,9 @@ def import_payload(connection, payload, imported_at=None):
INVARIANT: validation and every child insert share one IMMEDIATE transaction;
any failure leaves no draft, child, or import-identity row behind.
"""
supported_versions = range(18, 30)
supported_versions = range(18, 31)
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
raise ImportFailure("schema desktop v18-v29 requis")
raise ImportFailure("schema desktop v18-v30 requis")
draft, entries = validate(payload, connection)
digest = canonical_digest(payload)
imported_at = imported_at or dt.datetime.now(dt.timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")

View file

@ -206,9 +206,9 @@ def main():
payload = json.loads(args.artifact.read_text(encoding="utf-8"))
connection = connect_database(args.database)
try:
supported_versions = range(8, 30)
supported_versions = range(8, 31)
if connection.execute("PRAGMA user_version;").fetchone()[0] not in supported_versions:
fail("schema desktop v8 à v21 requis")
fail("schema desktop v8 à v30 requis")
proof_path = args.mobile_export
if proof_path is None:
candidate = args.artifact.with_name("trainlog-mobile-export-v2.json")

View file

@ -53,9 +53,9 @@ def validate(payload, reserved):
def apply_definitions(connection, definitions, complete_causal_envelope=False):
"""Apply validated definitions without owning the surrounding transaction."""
supported_versions = range(8, 30)
supported_versions = range(8, 31)
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
fail("schema desktop v8 à v21 requis")
fail("schema desktop v8 à v30 requis")
if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0] >= 20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='custom_equipment' AND deleted=1 LIMIT 1").fetchone():
fail("causal equipment protection requires the staged artifact")
imported = skipped = 0

View file

@ -56,9 +56,9 @@ def load(path):
def apply_aliases(con, aliases):
"""Apply parsed aliases inside the caller-owned transaction."""
supported_versions = range(12, 30)
supported_versions = range(12, 31)
if con.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
fail("schema desktop v12-v21 requis")
fail("schema desktop v12-v30 requis")
for source, canonical in aliases:
target = con.execute("SELECT id,tracking_mode,recording_mode,data_fields FROM exercises WHERE exercise_id=?", (canonical,)).fetchone()
if target is None: fail(f"cible canonique absente: {canonical}")

View file

@ -151,9 +151,9 @@ def replace(connection, row_id, primary, secondary):
def apply_body_zones(connection, parsed, mobile_proof, complete_causal_envelope=False):
"""Apply validated zone rows without owning transaction boundaries."""
supported_versions = range(11, 30)
supported_versions = range(11, 31)
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
raise ImportFailure("schema desktop v11-v29 requis")
raise ImportFailure("schema desktop v11-v30 requis")
if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0]>=20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='body_zone_relation' AND deleted=1 LIMIT 1").fetchone(): raise ImportFailure("causal BODY ZONES protection requires the staged artifact")
connection.execute("PRAGMA foreign_keys=ON");grouped={}
# CONTRACT: resolve complete alias groups before the first write; direct

View file

@ -50,8 +50,8 @@ def apply_profile_state(con, root, allow_pending=False):
if not isinstance(root,dict) or set(root)!={"format","version","generated_at","exercises"} or root["format"]!="trainlog-exercise-profile-state" or type(root["version"]) is not int or root["version"]!=1 or not isinstance(root["exercises"],list): fail("enveloppe profile-state invalide")
parse_timestamp(root["generated_at"], "generated_at");con.row_factory=sqlite3.Row
if con.execute("PRAGMA user_version").fetchone()[0] not in (
17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29):
fail("schema desktop v17-v29 requis")
17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30):
fail("schema desktop v17-v30 requis")
applied=equal=ancestor=pending=0;seen=set()
for item in root["exercises"]:
validate(item);eid=item["exercise_id"]

View file

@ -847,9 +847,9 @@ def require_supported_schema(connection):
# CONTRACT: v9 owns explicit max_results; earlier supported schemas remain
# readable for legacy artifacts and are never made to fake that table.
if version not in range(5, 30):
if version not in range(5, 31):
raise ImportFailure(
f"base desktop schema v5 à v29 attendue, version trouvée: {version}"
f"base desktop schema v5 à v30 attendue, version trouvée: {version}"
)
@ -2002,8 +2002,8 @@ def apply_payload(connection, payload, trace_exercises=False, complete_causal_en
require_supported_schema(connection)
schema_version = connection.execute("PRAGMA user_version;").fetchone()[0]
if payload["version"] == 4 and schema_version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29):
raise ImportFailure("mobile V4 exige le schéma desktop v19 à v29")
if payload["version"] == 4 and schema_version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30):
raise ImportFailure("mobile V4 exige le schéma desktop v19 à v30")
if schema_version >= 20:
protected = {(row[0], row[1]) for row in connection.execute(
"SELECT target_kind,target_id FROM sync_causal_state WHERE deleted=1")}

View file

@ -52,9 +52,9 @@ def merge_revisions(db,table,parent_column,parent_id,revisions):
def apply_feedback(db,root,complete_causal_envelope=False):
roots_added=roots_skipped=revisions_added=revisions_skipped=0
db.execute("PRAGMA foreign_keys=ON")
supported_versions = range(15, 30)
supported_versions = range(15, 31)
if db.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
fail("schema desktop v15-v29 requis")
fail("schema desktop v15-v30 requis")
if not complete_causal_envelope and db.execute("PRAGMA user_version").fetchone()[0]>=20 and db.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='feedback' AND deleted=1 LIMIT 1").fetchone():fail("causal feedback protection requires the staged artifact")
seen=set()
for index,item in enumerate(root["exercise_feedback"]):

View file

@ -18,6 +18,7 @@ RUNTIME_TOOLS = [
"sync_drive_transport.py",
"sync_generation_exchange.py",
"sleep_diary_exchange.py",
"heart_rate_exchange.py",
"trainlog_sqlite.py",
"validate_json.py",
"validate_training_knowledge.py",
@ -114,8 +115,8 @@ def main() -> int:
["git", "rev-parse", "HEAD"], cwd=ROOT, text=True
).strip(),
"product_version": "0.1.5",
"desktop_schema": 29,
"android_schema": 26,
"desktop_schema": 30,
"android_schema": 27,
"protocols": [
"mobile-export-v3",
"mobile-history-v4",
@ -130,6 +131,7 @@ def main() -> int:
"programs-v1",
"program-executions-v1",
"sleep-diary-v1",
"heart-rate-v1",
],
"entry_points": [
"bin/trainlog",

View file

@ -87,7 +87,7 @@ def load(path: Path) -> dict:
def apply_executions(db: sqlite3.Connection, root: dict) -> tuple[int, int, int]:
if db.execute("PRAGMA user_version").fetchone()[0] not in (27, 28, 29):
if db.execute("PRAGMA user_version").fetchone()[0] not in (27, 28, 29, 30):
fail("desktop schema v27, v28 or v29 required")
added = 0
advanced = 0

View file

@ -422,8 +422,8 @@ def main() -> None:
import_command.add_argument("--database", required=True, type=Path)
args = parser.parse_args()
with connect_database(args.database) as db:
if db.execute("PRAGMA user_version").fetchone()[0] != 29:
fail("desktop schema v29 required")
if db.execute("PRAGMA user_version").fetchone()[0] != 30:
fail("desktop schema v30 required")
if args.command == "export":
args.output.write_text(json.dumps(build(db), ensure_ascii=False, sort_keys=True,
separators=(",", ":")), encoding="utf-8")

View file

@ -21,6 +21,7 @@ from pathlib import Path, PurePosixPath
from trainlog_sqlite import connect_database
import causal_delete_exchange
import execution_draft_exchange
import heart_rate_exchange
import import_equipment_associations
import import_equipment_definitions
import import_exercise_aliases
@ -81,6 +82,7 @@ ARTIFACTS = (
)
SUPPORTED = {(row[1], row[2]) for row in ARTIFACTS} | {
("trainlog-program-executions", 1),
("trainlog-heart-rate", 1),
}
@ -209,7 +211,7 @@ def validate_manifest_bytes(raw: bytes, expected_consumer: str | None = None):
def require_schema(db: sqlite3.Connection) -> None:
supported_versions = (24, 25, 26, 27, 28, 29)
supported_versions = (24, 25, 26, 27, 28, 29, 30)
if db.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
raise GenerationError("desktop schema v24, v25, v26, v27, v28 or v29 required")
@ -667,6 +669,8 @@ def consume_desktop(database: Path, directory: Path) -> dict:
feedback = import_training_feedback.load(required("feedback"))
sleep_diary = (sleep_diary_exchange.load(listed["sleep-diary"])
if "sleep-diary" in listed else None)
heart_rate = (heart_rate_exchange.load(listed["heart-rate"])
if "heart-rate" in listed else None)
program_executions = (
program_execution_exchange.load(listed["program-executions"])
if "program-executions" in listed else None
@ -710,6 +714,8 @@ def consume_desktop(database: Path, directory: Path) -> dict:
execution_draft_exchange.import_document(db, drafts, own_transaction=False)
if sleep_diary is not None:
sleep_diary_exchange.apply(db, sleep_diary)
if heart_rate is not None:
heart_rate_exchange.apply(db, heart_rate)
if program_executions is not None:
program_execution_exchange.apply_executions(db, program_executions)
ack = record_consumed(db, manifest, manifest_digest)

View file

@ -17,7 +17,7 @@
#include "trainlog/model.h"
#include "trainlog/status.h"
#define TRAINLOG_DATABASE_SCHEMA_VERSION 29
#define TRAINLOG_DATABASE_SCHEMA_VERSION 30
typedef struct TrainlogDatabase TrainlogDatabase;

View file

@ -823,6 +823,14 @@ test_schema_v29_migration = executable(
)
test('schema_v29_migration', test_schema_v29_migration)
test_schema_v30_migration = executable(
'test_schema_v30_migration',
'tests/test_schema_v30_migration.c',
dependencies: trainlog_core_dep,
c_args: strict_c_args,
)
test('schema_v30_migration', test_schema_v30_migration)
test_schema_v7_migration = executable(
'test_schema_v7_migration',
'tests/test_schema_v7_migration.c',
@ -902,6 +910,11 @@ test(
find_program('python3'),
args: [meson.project_source_root() / 'tests/test_sleep_diary_exchange.py'],
)
test(
'heart_rate_exchange',
find_program('python3'),
args: [meson.project_source_root() / 'tests/test_heart_rate_exchange.py'],
)
test(
'session_preparation_export',
find_program('python3'),

View file

@ -1343,6 +1343,40 @@ static const char *const MIGRATE_V28_TO_V29_SQL =
"PRIMARY KEY(generation_id,entry_id));"
"PRAGMA user_version=29;COMMIT;";
/* WHY: heart-rate is Android-owned measured field data, but desktop remains
* the canonical long-term synchronized store. CONTRACT: v30 adds only the
* immutable imported capture/sample tables. INVARIANT: no workout, Sleep
* Diary, generation or existing observation row is rewritten or inferred. */
static const char *const MIGRATE_V29_TO_V30_SQL =
"BEGIN IMMEDIATE;"
"CREATE TABLE IF NOT EXISTS heart_rate_captures("
"capture_id TEXT PRIMARY KEY,context_kind TEXT NOT NULL CHECK(context_kind IN('session','cardio','sleep')),"
"context_id TEXT NOT NULL,started_at TEXT NOT NULL,ended_at TEXT NOT NULL,sensor_name TEXT,"
"imported_at TEXT NOT NULL,CHECK(capture_id GLOB 'hrc_*'),"
"CHECK((context_kind IN('session','cardio') AND context_id GLOB 'se_*') OR "
"(context_kind='sleep' AND context_id GLOB 'sl_*')),"
"CHECK(sensor_name IS NULL OR length(CAST(sensor_name AS BLOB))<=160));"
"CREATE TABLE IF NOT EXISTS heart_rate_samples("
"capture_id TEXT NOT NULL REFERENCES heart_rate_captures(capture_id) ON DELETE CASCADE,"
"sequence INTEGER NOT NULL CHECK(sequence>=0),observed_at TEXT NOT NULL,"
"bpm INTEGER NOT NULL CHECK(bpm BETWEEN 0 AND 65535),exercise_entry_id TEXT,"
"sensor_contact_detected INTEGER CHECK(sensor_contact_detected IS NULL OR "
"sensor_contact_detected IN(0,1)),energy_expended INTEGER CHECK(energy_expended IS NULL OR "
"energy_expended BETWEEN 0 AND 65535),PRIMARY KEY(capture_id,sequence),"
"CHECK(exercise_entry_id IS NULL OR exercise_entry_id GLOB 'sxe_*'));"
"CREATE TABLE IF NOT EXISTS heart_rate_rr_intervals("
"capture_id TEXT NOT NULL,sample_sequence INTEGER NOT NULL,"
"rr_index INTEGER NOT NULL CHECK(rr_index BETWEEN 0 AND 63),"
"value_1024 INTEGER NOT NULL CHECK(value_1024 BETWEEN 0 AND 65535),"
"PRIMARY KEY(capture_id,sample_sequence,rr_index),"
"FOREIGN KEY(capture_id,sample_sequence) REFERENCES "
"heart_rate_samples(capture_id,sequence) ON DELETE CASCADE);"
"CREATE INDEX IF NOT EXISTS heart_rate_samples_time ON "
"heart_rate_samples(observed_at,capture_id,sequence);"
"CREATE INDEX IF NOT EXISTS heart_rate_captures_context ON "
"heart_rate_captures(context_kind,context_id,started_at);"
"PRAGMA user_version=30;COMMIT;";
/* WHY: schema v29 was already opened on the private 0.1.4 review installation
* before medication capture joined the same unreleased migration. CONTRACT:
* this additive repair is identical to the tail of v28 -> v29 and runs only
@ -2176,7 +2210,7 @@ static TrainlogStatus initialize_or_validate_schema(TrainlogDatabase *database,
} else if (version == 11 || version == 12 || version == 13 || version == 14 || version == 15 ||
version == 16 || version == 17 || version == 18 || version == 19 || version == 20 ||
version == 21 || version == 22 || version == 23 || version == 24 || version == 25 ||
version == 26 || version == 27 || version == 28 || version == 29) {
version == 26 || version == 27 || version == 28 || version == 29 || version == 30) {
status = TRAINLOG_STATUS_OK;
} else {
if (version == 1) {
@ -2324,6 +2358,9 @@ static TrainlogStatus initialize_or_validate_schema(TrainlogDatabase *database,
if (status == TRAINLOG_STATUS_OK) {
status = execute_sql(database, ENSURE_V29_SLEEP_MEDICATION_SQL);
}
if (status == TRAINLOG_STATUS_OK && version < 30) {
status = execute_sql(database, MIGRATE_V29_TO_V30_SQL);
}
if (status == TRAINLOG_STATUS_OK) {
status = ensure_v18_ai_draft_publication_state(database);
}
@ -2343,7 +2380,7 @@ static TrainlogStatus initialize_or_validate_schema(TrainlogDatabase *database,
if (status != TRAINLOG_STATUS_OK) {
set_open_diagnostic(output_diagnostic,
output_diagnostic_capacity,
version == 0 ? "create schema v29" : "migrate database to schema v29",
version == 0 ? "create schema v30" : "migrate database to schema v30",
database->connection,
SQLITE_ERROR);
(void)sqlite3_exec(database->connection, "ROLLBACK;", NULL, NULL, NULL);

View file

@ -49,7 +49,7 @@ int main(void) {
trainlog_database_close(production);
production = NULL;
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
CHECK(scalar(raw, "PRAGMA user_version") == 29);
CHECK(scalar(raw, "PRAGMA user_version") == 30);
CHECK(scalar(raw,
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name IN "
"('sleep_diary_entries','sleep_diary_revisions','sleep_diary_events',"
@ -61,6 +61,9 @@ int main(void) {
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name IN "
"('sleep_medications','sleep_medication_revisions',"
"'sleep_medication_intakes')") == 3);
CHECK(scalar(raw,
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name IN "
"('heart_rate_captures','heart_rate_samples','heart_rate_rr_intervals')") == 3);
CHECK(scalar(raw, "SELECT COUNT(*) FROM pragma_foreign_key_check") == 0);
CHECK(scalar(raw, "SELECT COUNT(*) FROM pragma_integrity_check WHERE integrity_check='ok'") ==
1);
@ -79,13 +82,13 @@ int main(void) {
NULL) == SQLITE_OK);
CHECK(sqlite3_close(raw) == SQLITE_OK);
raw = NULL;
/* An unpublished v29 may already exist locally. Opening it completes the
* same v29 contract without rewriting user data or changing user_version. */
/* A valid v29 may already exist locally. Opening it completes the historical
* v29 repair and then advances additively to the current v30 schema. */
CHECK(trainlog_database_open(path, &production) == TRAINLOG_STATUS_OK);
trainlog_database_close(production);
production = NULL;
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
CHECK(scalar(raw, "PRAGMA user_version") == 29);
CHECK(scalar(raw, "PRAGMA user_version") == 30);
CHECK(scalar(raw,
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name LIKE "
"'sleep_medication%'") == 3);
@ -99,7 +102,7 @@ cleanup:
}
(void)unlink(path);
if (result == EXIT_SUCCESS) {
puts("PASS schema v28 to v29 migration and reopen");
puts("PASS schema v28 through v29 to current migration and reopen");
}
return result;
}

View file

@ -0,0 +1,75 @@
#define _POSIX_C_SOURCE 200809L
#include <sqlite3.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include "trainlog/database.h"
#define CHECK(value) \
do { \
if (!(value)) { \
fprintf(stderr, "CHECK failed line %d: %s\n", __LINE__, #value); \
goto cleanup; \
} \
} while (0)
static int scalar(sqlite3 *database, const char *sql) {
sqlite3_stmt *statement = NULL;
int value = -1;
if (sqlite3_prepare_v2(database, sql, -1, &statement, NULL) == SQLITE_OK &&
sqlite3_step(statement) == SQLITE_ROW) {
value = sqlite3_column_int(statement, 0);
}
(void)sqlite3_finalize(statement);
return value;
}
int main(void) {
char path[] = "/tmp/trainlog-schema-v30-XXXXXX";
TrainlogDatabase *production = NULL;
sqlite3 *raw = NULL;
int descriptor = mkstemp(path);
int version = 0;
int result = EXIT_FAILURE;
CHECK(descriptor >= 0 && close(descriptor) == 0);
CHECK(trainlog_database_open(path, &production) == TRAINLOG_STATUS_OK);
trainlog_database_close(production);
production = NULL;
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
CHECK(sqlite3_exec(raw,
"DROP TABLE heart_rate_rr_intervals;"
"DROP TABLE heart_rate_samples;"
"DROP TABLE heart_rate_captures;"
"PRAGMA user_version=29;",
NULL, NULL, NULL) == SQLITE_OK);
CHECK(sqlite3_close(raw) == SQLITE_OK);
raw = NULL;
CHECK(trainlog_database_open(path, &production) == TRAINLOG_STATUS_OK);
CHECK(trainlog_database_schema_version(production, &version) == TRAINLOG_STATUS_OK);
CHECK(version == 30);
trainlog_database_close(production);
production = NULL;
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
CHECK(scalar(raw,
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name IN "
"('heart_rate_captures','heart_rate_samples','heart_rate_rr_intervals')") == 3);
CHECK(scalar(raw,
"SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name IN "
"('heart_rate_samples_time','heart_rate_captures_context')") == 2);
CHECK(scalar(raw, "SELECT COUNT(*) FROM pragma_foreign_key_check") == 0);
CHECK(scalar(raw, "SELECT COUNT(*) FROM pragma_integrity_check WHERE integrity_check='ok'") == 1);
result = EXIT_SUCCESS;
cleanup:
if (production != NULL) trainlog_database_close(production);
if (raw != NULL) (void)sqlite3_close(raw);
(void)unlink(path);
if (result == EXIT_SUCCESS) puts("PASS schema v29 to v30 heart-rate migration");
return result;
}

View file

@ -178,8 +178,8 @@ static bool test_v7_migration_sqlite_failure_has_diagnostic(void) {
TRAINLOG_STATUS_DATABASE_ERROR);
CHECK(database == NULL);
CHECK(strncmp(diagnostic,
"migrate database to schema v29: SQLite rc=",
strlen("migrate database to schema v29: SQLite rc=")) == 0);
"migrate database to schema v30: SQLite rc=",
strlen("migrate database to schema v30: SQLite rc=")) == 0);
CHECK(strstr(diagnostic, "extended_rc=") != NULL);
CHECK(strstr(diagnostic, "custom_equipment") != NULL);
CHECK(strstr(diagnostic, "already exists") != NULL);
@ -197,12 +197,12 @@ static bool test_newer_schema_has_application_diagnostic(void) {
CHECK(fd >= 0);
CHECK(close(fd) == 0);
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
CHECK(sqlite3_exec(raw, "PRAGMA user_version=30;", NULL, NULL, NULL) == SQLITE_OK);
CHECK(sqlite3_exec(raw, "PRAGMA user_version=31;", NULL, NULL, NULL) == SQLITE_OK);
CHECK(sqlite3_close(raw) == SQLITE_OK);
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic, sizeof(diagnostic)) ==
TRAINLOG_STATUS_SCHEMA_UNSUPPORTED);
CHECK(database == NULL);
CHECK(strstr(diagnostic, "schema version 30 is newer") != NULL);
CHECK(strstr(diagnostic, "schema version 31 is newer") != NULL);
CHECK(strstr(diagnostic, "SQLite") == NULL);
CHECK(unlink(path) == 0);
return true;

View file

@ -154,7 +154,7 @@ static bool test_v9_to_v10_failure_rolls_back(void) {
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic, sizeof(diagnostic)) ==
TRAINLOG_STATUS_DATABASE_ERROR);
CHECK(database == NULL);
CHECK(strstr(diagnostic, "migrate database to schema v29") != NULL);
CHECK(strstr(diagnostic, "migrate database to schema v30") != NULL);
CHECK(strstr(diagnostic, "performed_sets_v9") != NULL);
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);

View file

@ -308,7 +308,7 @@ static bool test_http_contract(TrainlogDatabase *database) {
CHECK(strstr(response, "Access-Control-Allow-Origin") == NULL);
CHECK(strstr(response,
"{\"api_version\":1,\"status\":\"ok\",\"product\":\"trainlog\","
"\"version\":\"0.1.4\"}") != NULL);
"\"version\":\"0.1.5\"}") != NULL);
{
char token[80];
CHECK(exchange(port,