From f33e99ebb06ed4ced748b1c07410684d631e8787 Mon Sep 17 00:00:00 2001
From: Bernd Helm
Date: Mon, 24 Aug 2026 20:13:35 +0200
Subject: [PATCH 01/45] feat(android): add hardware ground truth collector
---
.../noop/testcentre/GroundTruthCollector.kt | 280 ++++++++++++++++++
.../app/src/main/java/com/noop/ui/AppRoot.kt | 8 +-
.../com/noop/ui/GroundTruthCollectorScreen.kt | 200 +++++++++++++
.../main/java/com/noop/ui/TestCentreScreen.kt | 16 +-
android/app/src/main/res/values/strings.xml | 20 ++
5 files changed, 522 insertions(+), 2 deletions(-)
create mode 100644 android/app/src/main/java/com/noop/testcentre/GroundTruthCollector.kt
create mode 100644 android/app/src/main/java/com/noop/ui/GroundTruthCollectorScreen.kt
diff --git a/android/app/src/main/java/com/noop/testcentre/GroundTruthCollector.kt b/android/app/src/main/java/com/noop/testcentre/GroundTruthCollector.kt
new file mode 100644
index 0000000000..57a8c4cbf5
--- /dev/null
+++ b/android/app/src/main/java/com/noop/testcentre/GroundTruthCollector.kt
@@ -0,0 +1,280 @@
+package com.noop.testcentre
+
+import android.content.Context
+import android.content.Intent
+import android.os.Build
+import androidx.core.content.FileProvider
+import com.noop.BuildConfig
+import com.noop.data.WhoopRepository
+import com.noop.ingest.RawSensorExport
+import java.io.File
+import java.util.zip.ZipEntry
+import java.util.zip.ZipOutputStream
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import org.json.JSONObject
+
+/** App-private, explicitly user-started manual ground-truth capture. No production metric reads this. */
+class GroundTruthCollector private constructor(private val context: Context) {
+ data class KeyInfo(
+ val keyCode: Int,
+ val keyName: String,
+ val scanCode: Int,
+ val deviceId: Int,
+ val deviceName: String,
+ val source: Int,
+ )
+
+ data class Snapshot(
+ val active: Boolean,
+ val sessionId: String?,
+ val deviceId: String?,
+ val startedAtMs: Long,
+ val endedAtMs: Long,
+ val steps: Int,
+ val stairs: Int,
+ val lastKind: String?,
+ val lastKey: KeyInfo?,
+ val noopStepsAtStart: Int?,
+ val lastNoopSteps: Int?,
+ val exported: Boolean,
+ )
+
+ private val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
+ private val directory = File(context.filesDir, "ground-truth").apply { mkdirs() }
+
+ @Synchronized
+ fun snapshot(): Snapshot = Snapshot(
+ active = prefs.getBoolean("active", false),
+ sessionId = prefs.getString("sessionId", null),
+ deviceId = prefs.getString("deviceId", null),
+ startedAtMs = prefs.getLong("startedAtMs", 0L),
+ endedAtMs = prefs.getLong("endedAtMs", 0L),
+ steps = prefs.getInt("steps", 0),
+ stairs = prefs.getInt("stairs", 0),
+ lastKind = prefs.getString("lastKind", null),
+ lastKey = prefs.getString("lastKey", null)?.let(::decodeKey),
+ noopStepsAtStart = prefs.getInt("noopStepsAtStart", Int.MIN_VALUE).takeUnless { it == Int.MIN_VALUE },
+ lastNoopSteps = prefs.getInt("lastNoopSteps", Int.MIN_VALUE).takeUnless { it == Int.MIN_VALUE },
+ exported = prefs.getBoolean("exported", false),
+ )
+
+ @Synchronized
+ fun start(noopSteps: Int, deviceId: String, nowMs: Long = System.currentTimeMillis()): Snapshot {
+ check(snapshot().sessionId == null || snapshot().exported) { "Export the previous session before starting another" }
+ val id = nowMs.toString()
+ prefs.edit().clear()
+ .putBoolean("active", true)
+ .putBoolean("exported", false)
+ .putString("sessionId", id)
+ .putString("deviceId", deviceId)
+ .putLong("startedAtMs", nowMs)
+ .putInt("steps", 0)
+ .putInt("stairs", 0)
+ .applyNoopStart(noopSteps)
+ .applyNoop(noopSteps)
+ .apply()
+ append(id, event(nowMs, "start", 0, 0, noopSteps, null))
+ return snapshot()
+ }
+
+ @Synchronized
+ fun record(kind: String, key: KeyInfo, noopSteps: Int?, nowMs: Long = System.currentTimeMillis()): Snapshot {
+ val before = snapshot()
+ if (!before.active || before.sessionId == null) return before
+ val steps = before.steps + if (kind == KIND_STEP || kind == KIND_STAIR) 1 else 0
+ val stairs = before.stairs + if (kind == KIND_STAIR) 1 else 0
+ append(before.sessionId, event(nowMs, kind, steps, stairs, noopSteps, key))
+ prefs.edit()
+ .putInt("steps", steps)
+ .putInt("stairs", stairs)
+ .putString("lastKind", kind)
+ .putString("lastKey", encodeKey(key))
+ .applyNoop(noopSteps)
+ .apply()
+ return snapshot()
+ }
+
+ /** Record an unmapped key for diagnosis without changing either ground-truth counter. */
+ @Synchronized
+ fun observeKey(key: KeyInfo, noopSteps: Int?, nowMs: Long = System.currentTimeMillis()): Snapshot {
+ val before = snapshot()
+ prefs.edit().putString("lastKey", encodeKey(key)).applyNoop(noopSteps).apply()
+ if (before.active && before.sessionId != null) {
+ append(before.sessionId, event(nowMs, KIND_KEY, before.steps, before.stairs, noopSteps, key))
+ }
+ return snapshot()
+ }
+
+ @Synchronized
+ fun undo(noopSteps: Int?, nowMs: Long = System.currentTimeMillis()): Snapshot {
+ val before = snapshot()
+ if (!before.active || before.sessionId == null) return before
+ val undoKind = when (before.lastKind) {
+ KIND_STEP -> KIND_UNDO_STEP
+ KIND_STAIR -> KIND_UNDO_STAIR
+ else -> return before
+ }
+ val steps = (before.steps - 1).coerceAtLeast(0)
+ val stairs = (before.stairs - if (undoKind == KIND_UNDO_STAIR) 1 else 0).coerceAtLeast(0)
+ append(before.sessionId, event(nowMs, undoKind, steps, stairs, noopSteps, null))
+ prefs.edit().putInt("steps", steps).putInt("stairs", stairs).remove("lastKind")
+ .applyNoop(noopSteps).apply()
+ return snapshot()
+ }
+
+ @Synchronized
+ fun stop(noopSteps: Int?, nowMs: Long = System.currentTimeMillis()): Snapshot {
+ val before = snapshot()
+ if (!before.active || before.sessionId == null) return before
+ append(before.sessionId, event(nowMs, "stop", before.steps, before.stairs, noopSteps, null))
+ prefs.edit().putBoolean("active", false).putLong("endedAtMs", nowMs)
+ .applyNoop(noopSteps).apply()
+ return snapshot()
+ }
+
+ suspend fun export(repo: WhoopRepository): File = withContext(Dispatchers.IO) {
+ val snap = snapshot()
+ require(!snap.active) { "Stop the session before exporting" }
+ val id = requireNotNull(snap.sessionId) { "No ground-truth session has been recorded" }
+ val deviceId = requireNotNull(snap.deviceId) { "The session has no recorded device" }
+ val source = eventFile(id)
+ require(source.isFile) { "Ground-truth event file is missing" }
+ val events = source.useLines { lines -> lines.filter { it.isNotBlank() }.map(::JSONObject).toList() }
+ val endMs = snap.endedAtMs.takeIf { it > 0 } ?: System.currentTimeMillis()
+ val outDir = File(context.cacheDir, "logs").apply { mkdirs() }
+ val zip = File(outDir, "noop-ground-truth-$id.zip")
+ ZipOutputStream(zip.outputStream().buffered()).use { out ->
+ out.putNextEntry(ZipEntry("meta.json"))
+ out.writerEntry(JSONObject().apply {
+ put("schema_version", 1)
+ put("session_id", id)
+ put("device_id", deviceId)
+ put("started_at_ms", snap.startedAtMs)
+ put("ended_at_ms", endMs)
+ put("manual_steps", snap.steps)
+ put("manual_stairs", snap.stairs)
+ if (snap.noopStepsAtStart != null) put("noop_steps_at_start", snap.noopStepsAtStart)
+ if (snap.lastNoopSteps != null) put("noop_steps_at_end", snap.lastNoopSteps)
+ put("device_family", "Android")
+ put("app_version", BuildConfig.VERSION_NAME)
+ put("ground_truth", "JX-05S hardware clicker; ZOOM_OUT=step, ZOOM_IN=stair")
+ }.toString(2))
+
+ out.putNextEntry(ZipEntry("events.jsonl"))
+ source.inputStream().use { it.copyTo(out) }
+ out.closeEntry()
+
+ out.putNextEntry(ZipEntry("events.csv"))
+ out.writerEntry(eventsCsv(events))
+
+ out.putNextEntry(ZipEntry("seconds.csv"))
+ out.writerEntry(secondsCsv(events, snap.startedAtMs, endMs))
+
+ out.putNextEntry(ZipEntry("raw-sensors.csv"))
+ out.bufferedWriterNoClose().use { writer ->
+ RawSensorExport.writeCsv(
+ writer, repo, deviceId,
+ snap.startedAtMs / 1_000L - 5L,
+ endMs / 1_000L + 5L,
+ )
+ writer.flush()
+ }
+ out.closeEntry()
+ }
+ prefs.edit().putBoolean("exported", true).apply()
+ zip
+ }
+
+ fun share(file: File) {
+ val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
+ context.startActivity(Intent.createChooser(Intent(Intent.ACTION_SEND).apply {
+ type = "application/zip"
+ putExtra(Intent.EXTRA_STREAM, uri)
+ putExtra(Intent.EXTRA_SUBJECT, "NOOP ground-truth session")
+ addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
+ }, "Export ground-truth session"))
+ }
+
+ private fun eventFile(id: String) = File(directory, "session-$id.jsonl")
+ private fun append(id: String, value: JSONObject) = eventFile(id).appendText(value.toString() + "\n")
+
+ private fun event(
+ atMs: Long, kind: String, steps: Int, stairs: Int, noopSteps: Int?, key: KeyInfo?,
+ ) = JSONObject().apply {
+ put("at_ms", atMs); put("kind", kind); put("steps_total", steps); put("stairs_total", stairs)
+ if (noopSteps != null) put("noop_steps", noopSteps)
+ if (key != null) {
+ put("key_code", key.keyCode); put("key_name", key.keyName); put("scan_code", key.scanCode)
+ put("device_id", key.deviceId); put("device_name", key.deviceName); put("source", key.source)
+ }
+ }
+
+ private fun eventsCsv(events: List): String = buildString {
+ append("at_ms,unix_s,kind,steps_total,stairs_total,noop_steps,key_code,key_name,scan_code,device_id,device_name,source\n")
+ for (e in events) append(listOf(
+ e.optLong("at_ms"), e.optLong("at_ms") / 1_000L, e.optString("kind"),
+ e.optInt("steps_total"), e.optInt("stairs_total"), e.opt("noop_steps") ?: "",
+ e.opt("key_code") ?: "", e.optString("key_name"), e.opt("scan_code") ?: "",
+ e.opt("device_id") ?: "", e.optString("device_name"), e.opt("source") ?: "",
+ ).joinToString(",") { csv(it.toString()) }).append('\n')
+ }
+
+ private fun secondsCsv(events: List, startMs: Long, endMs: Long): String = buildString {
+ append("unix_s,manual_steps_delta,manual_stairs_delta,manual_steps_total,manual_stairs_total,noop_steps\n")
+ val bySecond = events.groupBy { it.optLong("at_ms") / 1_000L }
+ var steps = 0; var stairs = 0; var noop: Int? = null
+ for (second in startMs / 1_000L..endMs / 1_000L) {
+ val rows = bySecond[second].orEmpty()
+ val stepDelta = rows.count { it.optString("kind") == KIND_STEP || it.optString("kind") == KIND_STAIR } -
+ rows.count { it.optString("kind") == KIND_UNDO_STEP || it.optString("kind") == KIND_UNDO_STAIR }
+ val stairDelta = rows.count { it.optString("kind") == KIND_STAIR } - rows.count { it.optString("kind") == KIND_UNDO_STAIR }
+ rows.lastOrNull()?.let { last ->
+ steps = last.optInt("steps_total", steps); stairs = last.optInt("stairs_total", stairs)
+ if (last.has("noop_steps")) noop = last.optInt("noop_steps")
+ }
+ append("$second,$stepDelta,$stairDelta,$steps,$stairs,${noop ?: ""}\n")
+ }
+ }
+
+ private fun csv(value: String): String = if (value.any { it == ',' || it == '"' || it == '\n' })
+ "\"${value.replace("\"", "\"\"")}\"" else value
+
+ private fun encodeKey(key: KeyInfo) = JSONObject().apply {
+ put("keyCode", key.keyCode); put("keyName", key.keyName); put("scanCode", key.scanCode)
+ put("deviceId", key.deviceId); put("deviceName", key.deviceName); put("source", key.source)
+ }.toString()
+
+ private fun decodeKey(raw: String): KeyInfo = JSONObject(raw).let {
+ KeyInfo(it.getInt("keyCode"), it.getString("keyName"), it.getInt("scanCode"),
+ it.getInt("deviceId"), it.getString("deviceName"), it.getInt("source"))
+ }
+
+ private fun android.content.SharedPreferences.Editor.applyNoop(value: Int?) = apply {
+ if (value == null) remove("lastNoopSteps") else putInt("lastNoopSteps", value)
+ }
+
+ private fun android.content.SharedPreferences.Editor.applyNoopStart(value: Int?) = apply {
+ if (value == null) remove("noopStepsAtStart") else putInt("noopStepsAtStart", value)
+ }
+
+ private fun ZipOutputStream.writerEntry(text: String) {
+ write(text.toByteArray(Charsets.UTF_8)); closeEntry()
+ }
+
+ /** A Writer whose close only flushes, because closing it must not close the surrounding ZIP. */
+ private fun ZipOutputStream.bufferedWriterNoClose() = object : java.io.OutputStreamWriter(this, Charsets.UTF_8) {
+ override fun close() = flush()
+ }.buffered()
+
+ companion object {
+ const val KIND_STEP = "step"
+ const val KIND_STAIR = "stair"
+ private const val KIND_KEY = "key"
+ private const val KIND_UNDO_STEP = "undo_step"
+ private const val KIND_UNDO_STAIR = "undo_stair"
+ private const val PREFS = "noop_ground_truth_collector"
+
+ fun from(context: Context) = GroundTruthCollector(context.applicationContext)
+ }
+}
diff --git a/android/app/src/main/java/com/noop/ui/AppRoot.kt b/android/app/src/main/java/com/noop/ui/AppRoot.kt
index fc533ab50b..4b193ed77b 100644
--- a/android/app/src/main/java/com/noop/ui/AppRoot.kt
+++ b/android/app/src/main/java/com/noop/ui/AppRoot.kt
@@ -179,6 +179,7 @@ private enum class Destination(
Icons.Filled.Tune,
),
TestCentre("test_centre", R.string.nav_test_centre, Icons.Filled.BugReport),
+ GroundTruthCollector("ground_truth_collector", R.string.ground_truth_title, Icons.Filled.Sensors),
// The "More" tab: its own navigated page (mirroring the iOS More tab) that hosts the full
// grouped destination list. It is NOT itself in any [DrawerGroup] — it's the door to them.
@@ -468,7 +469,12 @@ fun AppRoot(viewModel: AppViewModel = viewModel()) {
onClose = { nav.popBackStack() },
)
}
- composable(Destination.TestCentre.route) { TestCentreScreen(viewModel) }
+ composable(Destination.TestCentre.route) {
+ TestCentreScreen(viewModel, onOpenGroundTruthCollector = {
+ nav.navigate(Destination.GroundTruthCollector.route)
+ })
+ }
+ composable(Destination.GroundTruthCollector.route) { GroundTruthCollectorScreen(viewModel) }
// The "More" page — the iOS More tab's twin: a navigated ScreenScaffold page hosting the
// full grouped destination list (was a pull-up sheet). A row pushes its destination so
// Android Back returns to More instead of skipping straight to Today.
diff --git a/android/app/src/main/java/com/noop/ui/GroundTruthCollectorScreen.kt b/android/app/src/main/java/com/noop/ui/GroundTruthCollectorScreen.kt
new file mode 100644
index 0000000000..443d7ee1b5
--- /dev/null
+++ b/android/app/src/main/java/com/noop/ui/GroundTruthCollectorScreen.kt
@@ -0,0 +1,200 @@
+package com.noop.ui
+
+import android.app.Activity
+import android.content.Context
+import android.content.ContextWrapper
+import android.os.Build
+import android.view.HapticFeedbackConstants
+import android.view.InputDevice
+import android.view.KeyEvent
+import android.view.WindowManager
+import android.widget.Toast
+import androidx.compose.foundation.focusable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Switch
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.input.key.onPreviewKeyEvent
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalView
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.noop.R
+import com.noop.testcentre.GroundTruthCollector
+import kotlinx.coroutines.launch
+
+/** Foreground-only hardware-clicker capture. Production steps never depend on this research surface. */
+@Composable
+fun GroundTruthCollectorScreen(vm: AppViewModel) {
+ val context = LocalContext.current
+ val activity = remember(context) { context.findActivity() }
+ val collector = remember { GroundTruthCollector.from(context) }
+ val focusRequester = remember { FocusRequester() }
+ val view = LocalView.current
+ val scope = rememberCoroutineScope()
+ val cycle by vm.activeDayCycle.collectAsStateWithLifecycle()
+ val days by vm.recentDays.collectAsStateWithLifecycle()
+ val noopSteps = cycle?.steps ?: days.lastOrNull()?.steps
+ var state by remember { mutableStateOf(collector.snapshot()) }
+ var haptics by remember { mutableStateOf(false) }
+ var exporting by remember { mutableStateOf(false) }
+
+ DisposableEffect(activity) {
+ val window = activity?.window
+ val oldBrightness = window?.attributes?.screenBrightness
+ window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
+ window?.attributes = window?.attributes?.apply { screenBrightness = 0.01f }
+ onDispose {
+ window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
+ if (oldBrightness != null) window.attributes = window.attributes.apply { screenBrightness = oldBrightness }
+ }
+ }
+ LaunchedEffect(Unit) { focusRequester.requestFocus() }
+
+ ScreenScaffold(
+ title = stringResource(R.string.ground_truth_title),
+ subtitle = stringResource(R.string.ground_truth_subtitle),
+ modifier = Modifier
+ .focusRequester(focusRequester)
+ .focusable()
+ .onPreviewKeyEvent { composeEvent ->
+ val event = composeEvent.nativeKeyEvent
+ if (event.action != KeyEvent.ACTION_DOWN || event.repeatCount != 0) return@onPreviewKeyEvent false
+ val key = event.toCollectorKey()
+ state = when (event.keyCode) {
+ KeyEvent.KEYCODE_ZOOM_OUT -> collector.record(GroundTruthCollector.KIND_STEP, key, noopSteps)
+ KeyEvent.KEYCODE_ZOOM_IN -> collector.record(GroundTruthCollector.KIND_STAIR, key, noopSteps)
+ else -> collector.observeKey(key, noopSteps)
+ }
+ val mapped = event.keyCode == KeyEvent.KEYCODE_ZOOM_OUT || event.keyCode == KeyEvent.KEYCODE_ZOOM_IN
+ if (mapped && haptics) {
+ view.performHapticFeedback(if (Build.VERSION.SDK_INT >= 30) HapticFeedbackConstants.CONFIRM else HapticFeedbackConstants.KEYBOARD_TAP)
+ }
+ mapped
+ },
+ ) {
+ NoopCard(tint = Palette.accent) {
+ Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
+ CounterColumn(stringResource(R.string.ground_truth_noop_steps), noopSteps?.toString() ?: "-")
+ CounterColumn(stringResource(R.string.ground_truth_manual_steps), state.steps.toString())
+ CounterColumn(stringResource(R.string.ground_truth_stairs), state.stairs.toString())
+ }
+ val noopSessionSteps = state.noopStepsAtStart?.let { start -> noopSteps?.minus(start) }
+ val delta = noopSessionSteps?.minus(state.steps)
+ Text(
+ text = stringResource(R.string.ground_truth_delta, delta?.toString() ?: "-"),
+ style = NoopType.subhead,
+ color = Palette.textSecondary,
+ modifier = Modifier.padding(top = 12.dp),
+ )
+ }
+
+ NoopCard {
+ Text(stringResource(R.string.ground_truth_mapping_title), style = NoopType.headline, color = Palette.textPrimary)
+ Text(stringResource(R.string.ground_truth_mapping_body), style = NoopType.body, color = Palette.textSecondary)
+ val key = state.lastKey
+ Text(
+ text = if (key == null) stringResource(R.string.ground_truth_no_key)
+ else stringResource(
+ R.string.ground_truth_key_detail,
+ key.keyName, key.keyCode, key.scanCode, key.deviceName, key.deviceId, key.source,
+ ),
+ style = NoopType.caption,
+ color = Palette.textSecondary,
+ modifier = Modifier.padding(top = 12.dp),
+ )
+ }
+
+ NoopCard {
+ Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
+ Column(Modifier.weight(1f)) {
+ Text(stringResource(R.string.ground_truth_haptics), style = NoopType.body, color = Palette.textPrimary)
+ Text(stringResource(R.string.ground_truth_haptics_desc), style = NoopType.caption, color = Palette.textSecondary)
+ }
+ Switch(checked = haptics, onCheckedChange = { haptics = it })
+ }
+ }
+
+ if (!state.active && (state.sessionId == null || state.exported)) {
+ NoopButton(
+ text = stringResource(R.string.ground_truth_start),
+ fullWidth = true,
+ enabled = noopSteps != null && vm.activeStrapId.isNotBlank(),
+ onClick = { state = collector.start(requireNotNull(noopSteps), vm.activeStrapId); focusRequester.requestFocus() },
+ )
+ } else {
+ Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
+ NoopButton(
+ text = stringResource(R.string.ground_truth_undo),
+ kind = NoopButtonKind.Secondary,
+ modifier = Modifier.weight(1f),
+ onClick = { state = collector.undo(noopSteps); focusRequester.requestFocus() },
+ )
+ NoopButton(
+ text = stringResource(R.string.ground_truth_stop),
+ kind = NoopButtonKind.Destructive,
+ modifier = Modifier.weight(1f),
+ onClick = { state = collector.stop(noopSteps); focusRequester.requestFocus() },
+ )
+ }
+ }
+ NoopButton(
+ text = if (exporting) stringResource(R.string.ground_truth_exporting) else stringResource(R.string.ground_truth_export),
+ kind = NoopButtonKind.Secondary,
+ fullWidth = true,
+ enabled = state.sessionId != null && !state.active && !exporting,
+ onClick = {
+ exporting = true
+ scope.launch {
+ runCatching { collector.export(vm.repo) }
+ .onSuccess { file -> state = collector.snapshot(); collector.share(file) }
+ .onFailure { Toast.makeText(context, context.getString(R.string.ground_truth_export_failed, it.message ?: "?"), Toast.LENGTH_LONG).show() }
+ exporting = false
+ focusRequester.requestFocus()
+ }
+ },
+ )
+ }
+}
+
+@Composable
+private fun CounterColumn(label: String, value: String) {
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ Text(value, style = NoopType.number(32f), color = Palette.textPrimary)
+ Text(label, style = NoopType.caption, color = Palette.textSecondary)
+ }
+}
+
+private fun KeyEvent.toCollectorKey(): GroundTruthCollector.KeyInfo {
+ val input = InputDevice.getDevice(deviceId)
+ return GroundTruthCollector.KeyInfo(
+ keyCode = keyCode,
+ keyName = KeyEvent.keyCodeToString(keyCode),
+ scanCode = scanCode,
+ deviceId = deviceId,
+ deviceName = input?.name ?: "unknown",
+ source = source,
+ )
+}
+
+private tailrec fun Context.findActivity(): Activity? = when (this) {
+ is Activity -> this
+ is ContextWrapper -> baseContext.findActivity()
+ else -> null
+}
diff --git a/android/app/src/main/java/com/noop/ui/TestCentreScreen.kt b/android/app/src/main/java/com/noop/ui/TestCentreScreen.kt
index 77e9480a5a..3290a5fe96 100644
--- a/android/app/src/main/java/com/noop/ui/TestCentreScreen.kt
+++ b/android/app/src/main/java/com/noop/ui/TestCentreScreen.kt
@@ -18,6 +18,7 @@ import androidx.compose.material.icons.filled.Autorenew
import androidx.compose.material.icons.filled.BugReport
import androidx.compose.material.icons.filled.DeleteOutline
import androidx.compose.material.icons.filled.Info
+import androidx.compose.material.icons.automirrored.filled.DirectionsWalk
import androidx.compose.material.icons.filled.Science
import androidx.compose.material.icons.filled.Upload
import androidx.compose.material3.AlertDialog
@@ -80,7 +81,7 @@ import kotlin.math.roundToInt
* scheduled-export / experimental controls on the same bindings the Settings cards use. No em-dash.
*/
@Composable
-fun TestCentreScreen(vm: AppViewModel) {
+fun TestCentreScreen(vm: AppViewModel, onOpenGroundTruthCollector: () -> Unit = {}) {
val context = LocalContext.current
val testCentre = remember { TestCentre.from(context) }
// CAPTURE-D: a UI scope to emit the data-volume line off the toggle-on path (a store read, so it can't
@@ -182,6 +183,19 @@ fun TestCentreScreen(vm: AppViewModel) {
// --- Section 2: Diagnostic tools ---
DiagnosticToolsCard(vm)
+ SettingsSectionTC(
+ icon = Icons.AutoMirrored.Filled.DirectionsWalk,
+ title = stringResource(R.string.ground_truth_title),
+ blurb = stringResource(R.string.ground_truth_test_centre_desc),
+ ) {
+ NoopButton(
+ text = stringResource(R.string.ground_truth_open),
+ kind = NoopButtonKind.Secondary,
+ fullWidth = true,
+ onClick = onOpenGroundTruthCollector,
+ )
+ }
+
// --- Section 3: Export and auto-export ---
ExportCard(
vm = vm,
diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml
index 6b31c3ce4e..9e5a3c56c3 100644
--- a/android/app/src/main/res/values/strings.xml
+++ b/android/app/src/main/res/values/strings.xml
@@ -2478,4 +2478,24 @@
Your strap reports a blood oxygen estimate, but it is unverified and off by default. Turn on the strap estimate in Settings to see it, or import a WHOOP export in Data Sources for the calibrated value.
No blood oxygen percentage from a WHOOP 4.0
Your strap banks the raw optical channels, but NOOP does not turn them into a blood oxygen percentage, so this stays empty however long you wear it. Import a WHOOP export in Data Sources to fill it from your account history.
+ Ground-truth collector
+ Count steps and stairs with a hardware button while NOOP records the matching sensor timeline.
+ A foreground hardware-clicker session for step-algorithm research. It never changes your health data.
+ Open collector
+ NOOP steps
+ Manual steps
+ Stairs
+ NOOP − manual: %1$s
+ Hardware mapping
+ Zoom Out adds one step. Zoom In adds one stair and one step. Every other key is shown and logged without changing a count.
+ Press a hardware button to inspect its key code.
+ %1$s · key %2$d · scan %3$d\n%4$s · device %5$d · source 0x%6$x
+ Vibrate on a counted press
+ Optional confirmation; disabled by default to save battery during long sessions.
+ Start new session
+ Undo last count
+ Stop session
+ Export session
+ Building export…
+ Couldn’t export session: %1$s
From ccc8c709f096a215f284c47046f66e24661ab228 Mon Sep 17 00:00:00 2001
From: Bernd Helm
Date: Mon, 24 Aug 2026 21:04:01 +0200
Subject: [PATCH 02/45] fix(android): stabilize ground truth capture
---
.../noop/testcentre/GroundTruthCollector.kt | 32 +++++++++++++
.../com/noop/ui/GroundTruthCollectorScreen.kt | 45 +++++++------------
2 files changed, 47 insertions(+), 30 deletions(-)
diff --git a/android/app/src/main/java/com/noop/testcentre/GroundTruthCollector.kt b/android/app/src/main/java/com/noop/testcentre/GroundTruthCollector.kt
index 57a8c4cbf5..996d1dc454 100644
--- a/android/app/src/main/java/com/noop/testcentre/GroundTruthCollector.kt
+++ b/android/app/src/main/java/com/noop/testcentre/GroundTruthCollector.kt
@@ -7,6 +7,7 @@ import androidx.core.content.FileProvider
import com.noop.BuildConfig
import com.noop.data.WhoopRepository
import com.noop.ingest.RawSensorExport
+import com.noop.protocol.Whoop5RawImu
import java.io.File
import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream
@@ -181,6 +182,13 @@ class GroundTruthCollector private constructor(private val context: Context) {
writer.flush()
}
out.closeEntry()
+
+ out.putNextEntry(ZipEntry("raw-imu.csv"))
+ out.bufferedWriterNoClose().use { writer ->
+ writeRawImuCsv(writer, repo, deviceId, snap.startedAtMs / 1_000L - 5L, endMs / 1_000L + 5L)
+ writer.flush()
+ }
+ out.closeEntry()
}
prefs.edit().putBoolean("exported", true).apply()
zip
@@ -240,6 +248,30 @@ class GroundTruthCollector private constructor(private val context: Context) {
private fun csv(value: String): String = if (value.any { it == ',' || it == '"' || it == '\n' })
"\"${value.replace("\"", "\"\"")}\"" else value
+ private suspend fun writeRawImuCsv(
+ out: java.io.Writer,
+ repo: WhoopRepository,
+ deviceId: String,
+ from: Long,
+ to: Long,
+ ) {
+ out.write("unix_s,sample_index,ax_g,ay_g,az_g,gx_dps,gy_dps,gz_dps\n")
+ for ((ts, columns) in repo.rawImuSamples(deviceId, from, to, limit = 200_000)) {
+ if (columns.size < Whoop5RawImu.sampleCount * 6) continue
+ for (i in 0 until Whoop5RawImu.sampleCount) {
+ val values = listOf(
+ columns[i] * Whoop5RawImu.accelScale,
+ columns[Whoop5RawImu.sampleCount + i] * Whoop5RawImu.accelScale,
+ columns[Whoop5RawImu.sampleCount * 2 + i] * Whoop5RawImu.accelScale,
+ columns[Whoop5RawImu.sampleCount * 3 + i] * Whoop5RawImu.gyroScale,
+ columns[Whoop5RawImu.sampleCount * 4 + i] * Whoop5RawImu.gyroScale,
+ columns[Whoop5RawImu.sampleCount * 5 + i] * Whoop5RawImu.gyroScale,
+ ).joinToString(",") { String.format(java.util.Locale.US, "%.8f", it) }
+ out.write("$ts,$i,$values\n")
+ }
+ }
+ }
+
private fun encodeKey(key: KeyInfo) = JSONObject().apply {
put("keyCode", key.keyCode); put("keyName", key.keyName); put("scanCode", key.scanCode)
put("deviceId", key.deviceId); put("deviceName", key.deviceName); put("source", key.source)
diff --git a/android/app/src/main/java/com/noop/ui/GroundTruthCollectorScreen.kt b/android/app/src/main/java/com/noop/ui/GroundTruthCollectorScreen.kt
index 443d7ee1b5..2e1dd250c3 100644
--- a/android/app/src/main/java/com/noop/ui/GroundTruthCollectorScreen.kt
+++ b/android/app/src/main/java/com/noop/ui/GroundTruthCollectorScreen.kt
@@ -1,13 +1,9 @@
package com.noop.ui
-import android.app.Activity
-import android.content.Context
-import android.content.ContextWrapper
import android.os.Build
import android.view.HapticFeedbackConstants
import android.view.InputDevice
import android.view.KeyEvent
-import android.view.WindowManager
import android.widget.Toast
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Arrangement
@@ -43,7 +39,6 @@ import kotlinx.coroutines.launch
@Composable
fun GroundTruthCollectorScreen(vm: AppViewModel) {
val context = LocalContext.current
- val activity = remember(context) { context.findActivity() }
val collector = remember { GroundTruthCollector.from(context) }
val focusRequester = remember { FocusRequester() }
val view = LocalView.current
@@ -55,17 +50,14 @@ fun GroundTruthCollectorScreen(vm: AppViewModel) {
var haptics by remember { mutableStateOf(false) }
var exporting by remember { mutableStateOf(false) }
- DisposableEffect(activity) {
- val window = activity?.window
- val oldBrightness = window?.attributes?.screenBrightness
- window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
- window?.attributes = window?.attributes?.apply { screenBrightness = 0.01f }
+ DisposableEffect(view) {
+ val oldKeepScreenOn = view.keepScreenOn
+ view.keepScreenOn = true
onDispose {
- window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
- if (oldBrightness != null) window.attributes = window.attributes.apply { screenBrightness = oldBrightness }
+ view.keepScreenOn = oldKeepScreenOn
}
}
- LaunchedEffect(Unit) { focusRequester.requestFocus() }
+ LaunchedEffect(state.active, state.steps, state.stairs, exporting) { focusRequester.requestFocus() }
ScreenScaffold(
title = stringResource(R.string.ground_truth_title),
@@ -131,28 +123,28 @@ fun GroundTruthCollectorScreen(vm: AppViewModel) {
}
}
- if (!state.active && (state.sessionId == null || state.exported)) {
- NoopButton(
- text = stringResource(R.string.ground_truth_start),
- fullWidth = true,
- enabled = noopSteps != null && vm.activeStrapId.isNotBlank(),
- onClick = { state = collector.start(requireNotNull(noopSteps), vm.activeStrapId); focusRequester.requestFocus() },
- )
- } else {
+ if (state.active) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
NoopButton(
text = stringResource(R.string.ground_truth_undo),
kind = NoopButtonKind.Secondary,
modifier = Modifier.weight(1f),
- onClick = { state = collector.undo(noopSteps); focusRequester.requestFocus() },
+ onClick = { state = collector.undo(noopSteps) },
)
NoopButton(
text = stringResource(R.string.ground_truth_stop),
kind = NoopButtonKind.Destructive,
modifier = Modifier.weight(1f),
- onClick = { state = collector.stop(noopSteps); focusRequester.requestFocus() },
+ onClick = { state = collector.stop(noopSteps) },
)
}
+ } else if (state.sessionId == null || state.exported) {
+ NoopButton(
+ text = stringResource(R.string.ground_truth_start),
+ fullWidth = true,
+ enabled = noopSteps != null && vm.activeStrapId.isNotBlank(),
+ onClick = { state = collector.start(requireNotNull(noopSteps), vm.activeStrapId) },
+ )
}
NoopButton(
text = if (exporting) stringResource(R.string.ground_truth_exporting) else stringResource(R.string.ground_truth_export),
@@ -166,7 +158,6 @@ fun GroundTruthCollectorScreen(vm: AppViewModel) {
.onSuccess { file -> state = collector.snapshot(); collector.share(file) }
.onFailure { Toast.makeText(context, context.getString(R.string.ground_truth_export_failed, it.message ?: "?"), Toast.LENGTH_LONG).show() }
exporting = false
- focusRequester.requestFocus()
}
},
)
@@ -192,9 +183,3 @@ private fun KeyEvent.toCollectorKey(): GroundTruthCollector.KeyInfo {
source = source,
)
}
-
-private tailrec fun Context.findActivity(): Activity? = when (this) {
- is Activity -> this
- is ContextWrapper -> baseContext.findActivity()
- else -> null
-}
From ec59ed89778b23f640f465c4e039343bc00c7692 Mon Sep 17 00:00:00 2001
From: Bernd Helm
Date: Mon, 24 Aug 2026 21:18:50 +0200
Subject: [PATCH 03/45] fix(android): clarify ground truth session layout
---
.../com/noop/ui/GroundTruthCollectorScreen.kt | 65 +++++++++++--------
android/app/src/main/res/values/strings.xml | 1 +
2 files changed, 38 insertions(+), 28 deletions(-)
diff --git a/android/app/src/main/java/com/noop/ui/GroundTruthCollectorScreen.kt b/android/app/src/main/java/com/noop/ui/GroundTruthCollectorScreen.kt
index 2e1dd250c3..cd91def2b5 100644
--- a/android/app/src/main/java/com/noop/ui/GroundTruthCollectorScreen.kt
+++ b/android/app/src/main/java/com/noop/ui/GroundTruthCollectorScreen.kt
@@ -29,6 +29,7 @@ import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.noop.R
@@ -82,35 +83,37 @@ fun GroundTruthCollectorScreen(vm: AppViewModel) {
},
) {
NoopCard(tint = Palette.accent) {
- Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
- CounterColumn(stringResource(R.string.ground_truth_noop_steps), noopSteps?.toString() ?: "-")
- CounterColumn(stringResource(R.string.ground_truth_manual_steps), state.steps.toString())
- CounterColumn(stringResource(R.string.ground_truth_stairs), state.stairs.toString())
+ Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
+ Row(Modifier.fillMaxWidth()) {
+ CounterColumn(stringResource(R.string.ground_truth_noop_steps), noopSteps?.toString() ?: "-", Modifier.weight(1f))
+ CounterColumn(stringResource(R.string.ground_truth_manual_steps), state.steps.toString(), Modifier.weight(1f))
+ CounterColumn(stringResource(R.string.ground_truth_stairs), state.stairs.toString(), Modifier.weight(1f))
+ }
+ val noopSessionSteps = state.noopStepsAtStart?.let { start -> noopSteps?.minus(start) }
+ val delta = noopSessionSteps?.minus(state.steps)
+ Text(
+ text = stringResource(R.string.ground_truth_delta, delta?.toString() ?: "-"),
+ style = NoopType.subhead,
+ color = Palette.textSecondary,
+ )
}
- val noopSessionSteps = state.noopStepsAtStart?.let { start -> noopSteps?.minus(start) }
- val delta = noopSessionSteps?.minus(state.steps)
- Text(
- text = stringResource(R.string.ground_truth_delta, delta?.toString() ?: "-"),
- style = NoopType.subhead,
- color = Palette.textSecondary,
- modifier = Modifier.padding(top = 12.dp),
- )
}
NoopCard {
- Text(stringResource(R.string.ground_truth_mapping_title), style = NoopType.headline, color = Palette.textPrimary)
- Text(stringResource(R.string.ground_truth_mapping_body), style = NoopType.body, color = Palette.textSecondary)
- val key = state.lastKey
- Text(
- text = if (key == null) stringResource(R.string.ground_truth_no_key)
- else stringResource(
- R.string.ground_truth_key_detail,
- key.keyName, key.keyCode, key.scanCode, key.deviceName, key.deviceId, key.source,
- ),
- style = NoopType.caption,
- color = Palette.textSecondary,
- modifier = Modifier.padding(top = 12.dp),
- )
+ Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ Text(stringResource(R.string.ground_truth_mapping_title), style = NoopType.headline, color = Palette.textPrimary)
+ Text(stringResource(R.string.ground_truth_mapping_body), style = NoopType.body, color = Palette.textSecondary)
+ val key = state.lastKey
+ Text(
+ text = if (key == null) stringResource(R.string.ground_truth_no_key)
+ else stringResource(
+ R.string.ground_truth_key_detail,
+ key.keyName, key.keyCode, key.scanCode, key.deviceName, key.deviceId, key.source,
+ ),
+ style = NoopType.caption,
+ color = Palette.textSecondary,
+ )
+ }
}
NoopCard {
@@ -145,6 +148,12 @@ fun GroundTruthCollectorScreen(vm: AppViewModel) {
enabled = noopSteps != null && vm.activeStrapId.isNotBlank(),
onClick = { state = collector.start(requireNotNull(noopSteps), vm.activeStrapId) },
)
+ } else {
+ Text(
+ text = stringResource(R.string.ground_truth_stopped_export_hint),
+ style = NoopType.subhead,
+ color = Palette.textSecondary,
+ )
}
NoopButton(
text = if (exporting) stringResource(R.string.ground_truth_exporting) else stringResource(R.string.ground_truth_export),
@@ -165,10 +174,10 @@ fun GroundTruthCollectorScreen(vm: AppViewModel) {
}
@Composable
-private fun CounterColumn(label: String, value: String) {
- Column(horizontalAlignment = Alignment.CenterHorizontally) {
+private fun CounterColumn(label: String, value: String, modifier: Modifier = Modifier) {
+ Column(modifier = modifier.padding(horizontal = 4.dp), horizontalAlignment = Alignment.CenterHorizontally) {
Text(value, style = NoopType.number(32f), color = Palette.textPrimary)
- Text(label, style = NoopType.caption, color = Palette.textSecondary)
+ Text(label, style = NoopType.footnote, color = Palette.textSecondary, textAlign = TextAlign.Center, maxLines = 2)
}
}
diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml
index 9e5a3c56c3..1f003e41c4 100644
--- a/android/app/src/main/res/values/strings.xml
+++ b/android/app/src/main/res/values/strings.xml
@@ -2498,4 +2498,5 @@
Export session
Building export…
Couldn’t export session: %1$s
+ Session stopped. Export it before starting the next session.
From 7b9a3c4954b461008e6b950758aba133b7aeacaf Mon Sep 17 00:00:00 2001
From: Bernd Helm
Date: Mon, 24 Aug 2026 21:31:19 +0200
Subject: [PATCH 04/45] fix(android): make collector sessions restartable
---
.../main/java/com/noop/ingest/RawSensorExport.kt | 3 ++-
.../com/noop/testcentre/GroundTruthCollector.kt | 16 ++++++++++++----
.../com/noop/ui/GroundTruthCollectorScreen.kt | 8 +-------
android/app/src/main/res/values/strings.xml | 1 -
4 files changed, 15 insertions(+), 13 deletions(-)
diff --git a/android/app/src/main/java/com/noop/ingest/RawSensorExport.kt b/android/app/src/main/java/com/noop/ingest/RawSensorExport.kt
index 3e9028bc89..d1efed710c 100644
--- a/android/app/src/main/java/com/noop/ingest/RawSensorExport.kt
+++ b/android/app/src/main/java/com/noop/ingest/RawSensorExport.kt
@@ -66,6 +66,8 @@ object RawSensorExport {
to: Long,
limit: Int = 200_000,
): Map {
+ out.write(HEADER); out.write("\n")
+ if (from > to) return emptyMap()
// index: 0 hr_bpm,1 rr_ms,2 grav_x,3 grav_y,4 grav_z,5 step_counter,6 ppg_bpm,7 ppg_conf,
// 8 spo2_red,9 spo2_ir,10 skintemp_raw,11 resp_raw,12 band_sleep_state,13 event_kind,14 event_payload
val rows = ArrayList()
@@ -118,7 +120,6 @@ object RawSensorExport {
// Stable sort by ts asc (a stream's intra-ts order is its query's secondary key).
rows.sortBy { it.ts }
- out.write(HEADER); out.write("\n")
for (r in rows) { out.write(r.line); out.write("\n") }
return counts
}
diff --git a/android/app/src/main/java/com/noop/testcentre/GroundTruthCollector.kt b/android/app/src/main/java/com/noop/testcentre/GroundTruthCollector.kt
index 996d1dc454..80636e7f97 100644
--- a/android/app/src/main/java/com/noop/testcentre/GroundTruthCollector.kt
+++ b/android/app/src/main/java/com/noop/testcentre/GroundTruthCollector.kt
@@ -62,7 +62,6 @@ class GroundTruthCollector private constructor(private val context: Context) {
@Synchronized
fun start(noopSteps: Int, deviceId: String, nowMs: Long = System.currentTimeMillis()): Snapshot {
- check(snapshot().sessionId == null || snapshot().exported) { "Export the previous session before starting another" }
val id = nowMs.toString()
prefs.edit().clear()
.putBoolean("active", true)
@@ -143,6 +142,14 @@ class GroundTruthCollector private constructor(private val context: Context) {
require(source.isFile) { "Ground-truth event file is missing" }
val events = source.useLines { lines -> lines.filter { it.isNotBlank() }.map(::JSONObject).toList() }
val endMs = snap.endedAtMs.takeIf { it > 0 } ?: System.currentTimeMillis()
+ // Sensor data is useful only around manually marked ground truth. An abandoned collector left
+ // open overnight must not turn one tap on Export into a multi-gigabyte query/allocation.
+ val markedEvents = events.filter {
+ it.optString("kind") == KIND_STEP || it.optString("kind") == KIND_STAIR ||
+ it.optString("kind") == KIND_UNDO_STEP || it.optString("kind") == KIND_UNDO_STAIR
+ }
+ val sensorFrom = markedEvents.firstOrNull()?.optLong("at_ms")?.div(1_000L)?.minus(5L) ?: 1L
+ val sensorTo = markedEvents.lastOrNull()?.optLong("at_ms")?.div(1_000L)?.plus(5L) ?: 0L
val outDir = File(context.cacheDir, "logs").apply { mkdirs() }
val zip = File(outDir, "noop-ground-truth-$id.zip")
ZipOutputStream(zip.outputStream().buffered()).use { out ->
@@ -176,8 +183,8 @@ class GroundTruthCollector private constructor(private val context: Context) {
out.bufferedWriterNoClose().use { writer ->
RawSensorExport.writeCsv(
writer, repo, deviceId,
- snap.startedAtMs / 1_000L - 5L,
- endMs / 1_000L + 5L,
+ sensorFrom,
+ sensorTo,
)
writer.flush()
}
@@ -185,7 +192,7 @@ class GroundTruthCollector private constructor(private val context: Context) {
out.putNextEntry(ZipEntry("raw-imu.csv"))
out.bufferedWriterNoClose().use { writer ->
- writeRawImuCsv(writer, repo, deviceId, snap.startedAtMs / 1_000L - 5L, endMs / 1_000L + 5L)
+ writeRawImuCsv(writer, repo, deviceId, sensorFrom, sensorTo)
writer.flush()
}
out.closeEntry()
@@ -256,6 +263,7 @@ class GroundTruthCollector private constructor(private val context: Context) {
to: Long,
) {
out.write("unix_s,sample_index,ax_g,ay_g,az_g,gx_dps,gy_dps,gz_dps\n")
+ if (from > to) return
for ((ts, columns) in repo.rawImuSamples(deviceId, from, to, limit = 200_000)) {
if (columns.size < Whoop5RawImu.sampleCount * 6) continue
for (i in 0 until Whoop5RawImu.sampleCount) {
diff --git a/android/app/src/main/java/com/noop/ui/GroundTruthCollectorScreen.kt b/android/app/src/main/java/com/noop/ui/GroundTruthCollectorScreen.kt
index cd91def2b5..972103fbab 100644
--- a/android/app/src/main/java/com/noop/ui/GroundTruthCollectorScreen.kt
+++ b/android/app/src/main/java/com/noop/ui/GroundTruthCollectorScreen.kt
@@ -141,19 +141,13 @@ fun GroundTruthCollectorScreen(vm: AppViewModel) {
onClick = { state = collector.stop(noopSteps) },
)
}
- } else if (state.sessionId == null || state.exported) {
+ } else {
NoopButton(
text = stringResource(R.string.ground_truth_start),
fullWidth = true,
enabled = noopSteps != null && vm.activeStrapId.isNotBlank(),
onClick = { state = collector.start(requireNotNull(noopSteps), vm.activeStrapId) },
)
- } else {
- Text(
- text = stringResource(R.string.ground_truth_stopped_export_hint),
- style = NoopType.subhead,
- color = Palette.textSecondary,
- )
}
NoopButton(
text = if (exporting) stringResource(R.string.ground_truth_exporting) else stringResource(R.string.ground_truth_export),
diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml
index 1f003e41c4..9e5a3c56c3 100644
--- a/android/app/src/main/res/values/strings.xml
+++ b/android/app/src/main/res/values/strings.xml
@@ -2498,5 +2498,4 @@
Export session
Building export…
Couldn’t export session: %1$s
- Session stopped. Export it before starting the next session.
From 35af0527d57fa5f7a30dc50c5c21271e2b0fca13 Mon Sep 17 00:00:00 2001
From: Bernd Helm
Date: Mon, 24 Aug 2026 22:09:57 +0200
Subject: [PATCH 05/45] fix(android): launch collector export safely
---
.../noop/testcentre/GroundTruthCollector.kt | 5 +++--
.../com/noop/ui/GroundTruthCollectorScreen.kt | 21 +++++++++++++++----
2 files changed, 20 insertions(+), 6 deletions(-)
diff --git a/android/app/src/main/java/com/noop/testcentre/GroundTruthCollector.kt b/android/app/src/main/java/com/noop/testcentre/GroundTruthCollector.kt
index 80636e7f97..cee01e34df 100644
--- a/android/app/src/main/java/com/noop/testcentre/GroundTruthCollector.kt
+++ b/android/app/src/main/java/com/noop/testcentre/GroundTruthCollector.kt
@@ -208,7 +208,7 @@ class GroundTruthCollector private constructor(private val context: Context) {
putExtra(Intent.EXTRA_STREAM, uri)
putExtra(Intent.EXTRA_SUBJECT, "NOOP ground-truth session")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
- }, "Export ground-truth session"))
+ }, "Export ground-truth session").addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
}
private fun eventFile(id: String) = File(directory, "session-$id.jsonl")
@@ -264,7 +264,8 @@ class GroundTruthCollector private constructor(private val context: Context) {
) {
out.write("unix_s,sample_index,ax_g,ay_g,az_g,gx_dps,gy_dps,gz_dps\n")
if (from > to) return
- for ((ts, columns) in repo.rawImuSamples(deviceId, from, to, limit = 200_000)) {
+ val secondsInWindow = (to - from + 1L).coerceIn(1L, 86_400L).toInt()
+ for ((ts, columns) in repo.rawImuSamples(deviceId, from, to, limit = secondsInWindow)) {
if (columns.size < Whoop5RawImu.sampleCount * 6) continue
for (i in 0 until Whoop5RawImu.sampleCount) {
val values = listOf(
diff --git a/android/app/src/main/java/com/noop/ui/GroundTruthCollectorScreen.kt b/android/app/src/main/java/com/noop/ui/GroundTruthCollectorScreen.kt
index 972103fbab..064d183197 100644
--- a/android/app/src/main/java/com/noop/ui/GroundTruthCollectorScreen.kt
+++ b/android/app/src/main/java/com/noop/ui/GroundTruthCollectorScreen.kt
@@ -157,10 +157,23 @@ fun GroundTruthCollectorScreen(vm: AppViewModel) {
onClick = {
exporting = true
scope.launch {
- runCatching { collector.export(vm.repo) }
- .onSuccess { file -> state = collector.snapshot(); collector.share(file) }
- .onFailure { Toast.makeText(context, context.getString(R.string.ground_truth_export_failed, it.message ?: "?"), Toast.LENGTH_LONG).show() }
- exporting = false
+ try {
+ val file = collector.export(vm.repo)
+ state = collector.snapshot()
+ collector.share(file)
+ } catch (failure: Throwable) {
+ if (failure is kotlinx.coroutines.CancellationException) throw failure
+ Toast.makeText(
+ context,
+ context.getString(
+ R.string.ground_truth_export_failed,
+ "${failure.javaClass.simpleName}: ${failure.message ?: "unknown error"}",
+ ),
+ Toast.LENGTH_LONG,
+ ).show()
+ } finally {
+ exporting = false
+ }
}
},
)
From 9625c385a31c0bd29a3b12b0a46594951eae07f1 Mon Sep 17 00:00:00 2001
From: Bernd Helm
Date: Mon, 24 Aug 2026 22:12:00 +0200
Subject: [PATCH 06/45] fix(android): clarify collector undo action
---
android/app/src/main/res/values/strings.xml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml
index 9e5a3c56c3..7f764f1681 100644
--- a/android/app/src/main/res/values/strings.xml
+++ b/android/app/src/main/res/values/strings.xml
@@ -2493,7 +2493,7 @@
Vibrate on a counted press
Optional confirmation; disabled by default to save battery during long sessions.
Start new session
- Undo last count
+ Undo last click
Stop session
Export session
Building export…
From babc56cea1b49dd4f4666f9517cb3798ccd6000f Mon Sep 17 00:00:00 2001
From: Bernd Helm
Date: Mon, 24 Aug 2026 22:47:18 +0200
Subject: [PATCH 07/45] feat(android): manage ground truth sessions
---
.../noop/testcentre/GroundTruthCollector.kt | 152 +++++++++++++++---
.../com/noop/ui/GroundTruthCollectorScreen.kt | 140 ++++++++++++----
android/app/src/main/res/values/strings.xml | 7 +
3 files changed, 244 insertions(+), 55 deletions(-)
diff --git a/android/app/src/main/java/com/noop/testcentre/GroundTruthCollector.kt b/android/app/src/main/java/com/noop/testcentre/GroundTruthCollector.kt
index cee01e34df..3d9fb64ea8 100644
--- a/android/app/src/main/java/com/noop/testcentre/GroundTruthCollector.kt
+++ b/android/app/src/main/java/com/noop/testcentre/GroundTruthCollector.kt
@@ -41,6 +41,19 @@ class GroundTruthCollector private constructor(private val context: Context) {
val exported: Boolean,
)
+ data class SessionSummary(
+ val id: String,
+ val deviceId: String?,
+ val startedAtMs: Long,
+ val endedAtMs: Long?,
+ val steps: Int,
+ val stairs: Int,
+ val excludedWindows: Int,
+ val comment: String,
+ val active: Boolean,
+ val exported: Boolean,
+ )
+
private val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
private val directory = File(context.filesDir, "ground-truth").apply { mkdirs() }
@@ -63,7 +76,7 @@ class GroundTruthCollector private constructor(private val context: Context) {
@Synchronized
fun start(noopSteps: Int, deviceId: String, nowMs: Long = System.currentTimeMillis()): Snapshot {
val id = nowMs.toString()
- prefs.edit().clear()
+ prefs.edit()
.putBoolean("active", true)
.putBoolean("exported", false)
.putString("sessionId", id)
@@ -73,11 +86,67 @@ class GroundTruthCollector private constructor(private val context: Context) {
.putInt("stairs", 0)
.applyNoopStart(noopSteps)
.applyNoop(noopSteps)
+ .putString(sessionDeviceKey(id), deviceId)
+ .putLong(sessionStartKey(id), nowMs)
+ .putBoolean(sessionExportedKey(id), false)
.apply()
- append(id, event(nowMs, "start", 0, 0, noopSteps, null))
+ append(id, event(nowMs, "start", 0, 0, noopSteps, null).put("strap_device_id", deviceId))
+ return snapshot()
+ }
+
+ @Synchronized
+ fun excludeLastMinutes(minutes: Int, nowMs: Long = System.currentTimeMillis()): Snapshot {
+ require(minutes in 1..240) { "Minutes must be between 1 and 240" }
+ val before = snapshot()
+ if (!before.active || before.sessionId == null) return before
+ val fromMs = maxOf(before.startedAtMs, nowMs - minutes * 60_000L)
+ append(before.sessionId, event(nowMs, KIND_EXCLUDE, before.steps, before.stairs, before.lastNoopSteps, null).apply {
+ put("from_ms", fromMs)
+ put("to_ms", nowMs)
+ })
return snapshot()
}
+ @Synchronized
+ fun setComment(sessionId: String, comment: String) {
+ prefs.edit().putString(sessionCommentKey(sessionId), comment.take(4_000)).apply()
+ }
+
+ @Synchronized
+ fun sessions(): List {
+ val current = snapshot()
+ return directory.listFiles { file -> file.name.startsWith("session-") && file.name.endsWith(".jsonl") }
+ .orEmpty()
+ .mapNotNull { file ->
+ val id = file.name.removePrefix("session-").removeSuffix(".jsonl")
+ val rows = runCatching {
+ file.useLines { lines ->
+ lines.filter { it.isNotBlank() }
+ .mapNotNull { line -> runCatching { JSONObject(line) }.getOrNull() }
+ .toList()
+ }
+ }.getOrNull() ?: return@mapNotNull null
+ val first = rows.firstOrNull() ?: return@mapNotNull null
+ val last = rows.last()
+ val isCurrent = current.sessionId == id
+ SessionSummary(
+ id = id,
+ deviceId = prefs.getString(sessionDeviceKey(id), null)
+ ?: first.optString("strap_device_id").takeIf(String::isNotBlank)
+ ?: current.deviceId.takeIf { isCurrent },
+ startedAtMs = prefs.getLong(sessionStartKey(id), first.optLong("at_ms")),
+ endedAtMs = rows.lastOrNull { it.optString("kind") == "stop" }?.optLong("at_ms"),
+ steps = last.optInt("steps_total"),
+ stairs = last.optInt("stairs_total"),
+ excludedWindows = rows.count { it.optString("kind") == KIND_EXCLUDE },
+ comment = prefs.getString(sessionCommentKey(id), "").orEmpty(),
+ active = isCurrent && current.active,
+ exported = prefs.getBoolean(sessionExportedKey(id), isCurrent && current.exported),
+ )
+ }
+ .sortedByDescending { it.startedAtMs }
+ }
+
@Synchronized
fun record(kind: String, key: KeyInfo, noopSteps: Int?, nowMs: Long = System.currentTimeMillis()): Snapshot {
val before = snapshot()
@@ -129,41 +198,52 @@ class GroundTruthCollector private constructor(private val context: Context) {
if (!before.active || before.sessionId == null) return before
append(before.sessionId, event(nowMs, "stop", before.steps, before.stairs, noopSteps, null))
prefs.edit().putBoolean("active", false).putLong("endedAtMs", nowMs)
+ .putLong(sessionEndKey(before.sessionId), nowMs)
.applyNoop(noopSteps).apply()
return snapshot()
}
- suspend fun export(repo: WhoopRepository): File = withContext(Dispatchers.IO) {
+ suspend fun export(repo: WhoopRepository, sessionId: String? = null): File = withContext(Dispatchers.IO) {
val snap = snapshot()
- require(!snap.active) { "Stop the session before exporting" }
- val id = requireNotNull(snap.sessionId) { "No ground-truth session has been recorded" }
- val deviceId = requireNotNull(snap.deviceId) { "The session has no recorded device" }
+ val id = sessionId ?: requireNotNull(snap.sessionId) { "No ground-truth session has been recorded" }
+ val summary = sessions().firstOrNull { it.id == id } ?: error("Ground-truth session is missing")
+ require(!summary.active) { "Stop the session before exporting" }
+ val deviceId = summary.deviceId
val source = eventFile(id)
require(source.isFile) { "Ground-truth event file is missing" }
- val events = source.useLines { lines -> lines.filter { it.isNotBlank() }.map(::JSONObject).toList() }
- val endMs = snap.endedAtMs.takeIf { it > 0 } ?: System.currentTimeMillis()
+ val events = source.useLines { lines ->
+ lines.filter { it.isNotBlank() }
+ .mapNotNull { line -> runCatching { JSONObject(line) }.getOrNull() }
+ .toList()
+ }
+ val endMs = summary.endedAtMs ?: events.lastOrNull()?.optLong("at_ms") ?: summary.startedAtMs
// Sensor data is useful only around manually marked ground truth. An abandoned collector left
// open overnight must not turn one tap on Export into a multi-gigabyte query/allocation.
val markedEvents = events.filter {
it.optString("kind") == KIND_STEP || it.optString("kind") == KIND_STAIR ||
it.optString("kind") == KIND_UNDO_STEP || it.optString("kind") == KIND_UNDO_STAIR
}
- val sensorFrom = markedEvents.firstOrNull()?.optLong("at_ms")?.div(1_000L)?.minus(5L) ?: 1L
- val sensorTo = markedEvents.lastOrNull()?.optLong("at_ms")?.div(1_000L)?.plus(5L) ?: 0L
+ val exclusions = exclusionWindows(events)
+ val sensorBoundsMs = markedEvents.map { it.optLong("at_ms") } + exclusions.flatMap { listOf(it.first, it.second) }
+ val sensorFrom = if (deviceId == null) 1L else sensorBoundsMs.minOrNull()?.div(1_000L)?.minus(5L) ?: 1L
+ val sensorTo = if (deviceId == null) 0L else sensorBoundsMs.maxOrNull()?.div(1_000L)?.plus(5L) ?: 0L
val outDir = File(context.cacheDir, "logs").apply { mkdirs() }
val zip = File(outDir, "noop-ground-truth-$id.zip")
ZipOutputStream(zip.outputStream().buffered()).use { out ->
out.putNextEntry(ZipEntry("meta.json"))
out.writerEntry(JSONObject().apply {
- put("schema_version", 1)
+ put("schema_version", 2)
put("session_id", id)
- put("device_id", deviceId)
- put("started_at_ms", snap.startedAtMs)
+ if (deviceId != null) put("device_id", deviceId)
+ put("sensor_export_available", deviceId != null)
+ put("started_at_ms", summary.startedAtMs)
put("ended_at_ms", endMs)
- put("manual_steps", snap.steps)
- put("manual_stairs", snap.stairs)
- if (snap.noopStepsAtStart != null) put("noop_steps_at_start", snap.noopStepsAtStart)
- if (snap.lastNoopSteps != null) put("noop_steps_at_end", snap.lastNoopSteps)
+ put("manual_steps", summary.steps)
+ put("manual_stairs", summary.stairs)
+ put("comment", summary.comment)
+ put("excluded_windows", summary.excludedWindows)
+ if (id == snap.sessionId && snap.noopStepsAtStart != null) put("noop_steps_at_start", snap.noopStepsAtStart)
+ if (id == snap.sessionId && snap.lastNoopSteps != null) put("noop_steps_at_end", snap.lastNoopSteps)
put("device_family", "Android")
put("app_version", BuildConfig.VERSION_NAME)
put("ground_truth", "JX-05S hardware clicker; ZOOM_OUT=step, ZOOM_IN=stair")
@@ -177,12 +257,15 @@ class GroundTruthCollector private constructor(private val context: Context) {
out.writerEntry(eventsCsv(events))
out.putNextEntry(ZipEntry("seconds.csv"))
- out.writerEntry(secondsCsv(events, snap.startedAtMs, endMs))
+ out.writerEntry(secondsCsv(events, sensorFrom * 1_000L, sensorTo * 1_000L))
+
+ out.putNextEntry(ZipEntry("exclusions.csv"))
+ out.writerEntry(exclusionsCsv(events))
out.putNextEntry(ZipEntry("raw-sensors.csv"))
out.bufferedWriterNoClose().use { writer ->
RawSensorExport.writeCsv(
- writer, repo, deviceId,
+ writer, repo, deviceId.orEmpty(),
sensorFrom,
sensorTo,
)
@@ -192,12 +275,13 @@ class GroundTruthCollector private constructor(private val context: Context) {
out.putNextEntry(ZipEntry("raw-imu.csv"))
out.bufferedWriterNoClose().use { writer ->
- writeRawImuCsv(writer, repo, deviceId, sensorFrom, sensorTo)
+ writeRawImuCsv(writer, repo, deviceId.orEmpty(), sensorFrom, sensorTo)
writer.flush()
}
out.closeEntry()
}
- prefs.edit().putBoolean("exported", true).apply()
+ prefs.edit().putBoolean(sessionExportedKey(id), true).apply()
+ if (id == snap.sessionId) prefs.edit().putBoolean("exported", true).apply()
zip
}
@@ -236,8 +320,9 @@ class GroundTruthCollector private constructor(private val context: Context) {
}
private fun secondsCsv(events: List, startMs: Long, endMs: Long): String = buildString {
- append("unix_s,manual_steps_delta,manual_stairs_delta,manual_steps_total,manual_stairs_total,noop_steps\n")
+ append("unix_s,manual_steps_delta,manual_stairs_delta,manual_steps_total,manual_stairs_total,noop_steps,excluded\n")
val bySecond = events.groupBy { it.optLong("at_ms") / 1_000L }
+ val exclusions = exclusionWindows(events)
var steps = 0; var stairs = 0; var noop: Int? = null
for (second in startMs / 1_000L..endMs / 1_000L) {
val rows = bySecond[second].orEmpty()
@@ -248,10 +333,24 @@ class GroundTruthCollector private constructor(private val context: Context) {
steps = last.optInt("steps_total", steps); stairs = last.optInt("stairs_total", stairs)
if (last.has("noop_steps")) noop = last.optInt("noop_steps")
}
- append("$second,$stepDelta,$stairDelta,$steps,$stairs,${noop ?: ""}\n")
+ val excluded = exclusions.any { second * 1_000L <= it.second && second * 1_000L + 999L >= it.first }
+ append("$second,$stepDelta,$stairDelta,$steps,$stairs,${noop ?: ""},$excluded\n")
}
}
+ private fun exclusionsCsv(events: List): String = buildString {
+ append("from_ms,to_ms,from_unix_s,to_unix_s\n")
+ for ((from, to) in exclusionWindows(events)) append("$from,$to,${from / 1_000L},${to / 1_000L}\n")
+ }
+
+ private fun exclusionWindows(events: List): List> = events
+ .filter { it.optString("kind") == KIND_EXCLUDE }
+ .mapNotNull { row ->
+ val from = row.optLong("from_ms", -1L)
+ val to = row.optLong("to_ms", -1L)
+ if (from >= 0L && to >= from) from to to else null
+ }
+
private fun csv(value: String): String = if (value.any { it == ',' || it == '"' || it == '\n' })
"\"${value.replace("\"", "\"\"")}\"" else value
@@ -299,6 +398,12 @@ class GroundTruthCollector private constructor(private val context: Context) {
if (value == null) remove("noopStepsAtStart") else putInt("noopStepsAtStart", value)
}
+ private fun sessionDeviceKey(id: String) = "session.$id.deviceId"
+ private fun sessionStartKey(id: String) = "session.$id.startedAtMs"
+ private fun sessionEndKey(id: String) = "session.$id.endedAtMs"
+ private fun sessionCommentKey(id: String) = "session.$id.comment"
+ private fun sessionExportedKey(id: String) = "session.$id.exported"
+
private fun ZipOutputStream.writerEntry(text: String) {
write(text.toByteArray(Charsets.UTF_8)); closeEntry()
}
@@ -314,6 +419,7 @@ class GroundTruthCollector private constructor(private val context: Context) {
private const val KIND_KEY = "key"
private const val KIND_UNDO_STEP = "undo_step"
private const val KIND_UNDO_STAIR = "undo_stair"
+ private const val KIND_EXCLUDE = "exclude_window"
private const val PREFS = "noop_ground_truth_collector"
fun from(context: Context) = GroundTruthCollector(context.applicationContext)
diff --git a/android/app/src/main/java/com/noop/ui/GroundTruthCollectorScreen.kt b/android/app/src/main/java/com/noop/ui/GroundTruthCollectorScreen.kt
index 064d183197..1ccc3e6cd9 100644
--- a/android/app/src/main/java/com/noop/ui/GroundTruthCollectorScreen.kt
+++ b/android/app/src/main/java/com/noop/ui/GroundTruthCollectorScreen.kt
@@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
+import androidx.compose.material3.OutlinedTextField
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
@@ -30,6 +31,8 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.noop.R
@@ -49,7 +52,9 @@ fun GroundTruthCollectorScreen(vm: AppViewModel) {
val noopSteps = cycle?.steps ?: days.lastOrNull()?.steps
var state by remember { mutableStateOf(collector.snapshot()) }
var haptics by remember { mutableStateOf(false) }
- var exporting by remember { mutableStateOf(false) }
+ var exportingSessionId by remember { mutableStateOf(null) }
+ var excludeMinutes by remember { mutableStateOf("5") }
+ var sessions by remember { mutableStateOf(collector.sessions()) }
DisposableEffect(view) {
val oldKeepScreenOn = view.keepScreenOn
@@ -58,7 +63,7 @@ fun GroundTruthCollectorScreen(vm: AppViewModel) {
view.keepScreenOn = oldKeepScreenOn
}
}
- LaunchedEffect(state.active, state.steps, state.stairs, exporting) { focusRequester.requestFocus() }
+ LaunchedEffect(state.active, state.steps, state.stairs) { focusRequester.requestFocus() }
ScreenScaffold(
title = stringResource(R.string.ground_truth_title),
@@ -138,7 +143,27 @@ fun GroundTruthCollectorScreen(vm: AppViewModel) {
text = stringResource(R.string.ground_truth_stop),
kind = NoopButtonKind.Destructive,
modifier = Modifier.weight(1f),
- onClick = { state = collector.stop(noopSteps) },
+ onClick = { state = collector.stop(noopSteps); sessions = collector.sessions() },
+ )
+ }
+ Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {
+ OutlinedTextField(
+ value = excludeMinutes,
+ onValueChange = { value -> excludeMinutes = value.filter(Char::isDigit).take(3) },
+ label = { Text(stringResource(R.string.ground_truth_minutes)) },
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
+ singleLine = true,
+ modifier = Modifier.weight(1f),
+ )
+ NoopButton(
+ text = stringResource(R.string.ground_truth_exclude_recent),
+ kind = NoopButtonKind.Secondary,
+ modifier = Modifier.weight(2f),
+ enabled = excludeMinutes.toIntOrNull() in 1..240,
+ onClick = {
+ excludeMinutes.toIntOrNull()?.let { collector.excludeLastMinutes(it) }
+ sessions = collector.sessions()
+ },
)
}
} else {
@@ -146,37 +171,88 @@ fun GroundTruthCollectorScreen(vm: AppViewModel) {
text = stringResource(R.string.ground_truth_start),
fullWidth = true,
enabled = noopSteps != null && vm.activeStrapId.isNotBlank(),
- onClick = { state = collector.start(requireNotNull(noopSteps), vm.activeStrapId) },
+ onClick = {
+ state = collector.start(requireNotNull(noopSteps), vm.activeStrapId)
+ sessions = collector.sessions()
+ },
+ )
+ }
+ Text(stringResource(R.string.ground_truth_sessions), style = NoopType.title2, color = Palette.textPrimary)
+ if (sessions.isEmpty()) {
+ Text(stringResource(R.string.ground_truth_no_sessions), style = NoopType.body, color = Palette.textSecondary)
+ } else {
+ sessions.forEach { session ->
+ SessionCard(
+ session = session,
+ exporting = exportingSessionId == session.id,
+ onComment = { comment ->
+ collector.setComment(session.id, comment)
+ sessions = sessions.map { if (it.id == session.id) it.copy(comment = comment) else it }
+ },
+ onExport = {
+ exportingSessionId = session.id
+ scope.launch {
+ try {
+ collector.share(collector.export(vm.repo, session.id))
+ sessions = collector.sessions()
+ } catch (failure: Throwable) {
+ if (failure is kotlinx.coroutines.CancellationException) throw failure
+ Toast.makeText(context, context.getString(R.string.ground_truth_export_failed,
+ "${failure.javaClass.simpleName}: ${failure.message ?: "unknown error"}"), Toast.LENGTH_LONG).show()
+ } finally {
+ exportingSessionId = null
+ }
+ }
+ },
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun SessionCard(
+ session: GroundTruthCollector.SessionSummary,
+ exporting: Boolean,
+ onComment: (String) -> Unit,
+ onExport: () -> Unit,
+) {
+ val time = remember(session.startedAtMs) {
+ java.text.DateFormat.getDateTimeInstance(java.text.DateFormat.SHORT, java.text.DateFormat.SHORT)
+ .format(java.util.Date(session.startedAtMs))
+ }
+ NoopCard {
+ Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
+ Text(time, style = NoopType.headline, color = Palette.textPrimary)
+ Text(
+ stringResource(
+ R.string.ground_truth_session_summary,
+ session.steps,
+ session.stairs,
+ session.excludedWindows,
+ ),
+ style = NoopType.caption,
+ color = Palette.textSecondary,
+ )
+ OutlinedTextField(
+ value = session.comment,
+ onValueChange = onComment,
+ label = { Text(stringResource(R.string.ground_truth_comment)) },
+ modifier = Modifier.fillMaxWidth(),
+ minLines = 2,
+ maxLines = 4,
+ )
+ if (session.deviceId == null) {
+ Text(stringResource(R.string.ground_truth_legacy_no_sensors), style = NoopType.caption, color = Palette.textSecondary)
+ }
+ NoopButton(
+ text = if (exporting) stringResource(R.string.ground_truth_exporting) else stringResource(R.string.ground_truth_export),
+ kind = NoopButtonKind.Secondary,
+ fullWidth = true,
+ enabled = !session.active && !exporting,
+ onClick = onExport,
)
}
- NoopButton(
- text = if (exporting) stringResource(R.string.ground_truth_exporting) else stringResource(R.string.ground_truth_export),
- kind = NoopButtonKind.Secondary,
- fullWidth = true,
- enabled = state.sessionId != null && !state.active && !exporting,
- onClick = {
- exporting = true
- scope.launch {
- try {
- val file = collector.export(vm.repo)
- state = collector.snapshot()
- collector.share(file)
- } catch (failure: Throwable) {
- if (failure is kotlinx.coroutines.CancellationException) throw failure
- Toast.makeText(
- context,
- context.getString(
- R.string.ground_truth_export_failed,
- "${failure.javaClass.simpleName}: ${failure.message ?: "unknown error"}",
- ),
- Toast.LENGTH_LONG,
- ).show()
- } finally {
- exporting = false
- }
- }
- },
- )
}
}
diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml
index 7f764f1681..883a7369c4 100644
--- a/android/app/src/main/res/values/strings.xml
+++ b/android/app/src/main/res/values/strings.xml
@@ -2498,4 +2498,11 @@
Export session
Building export…
Couldn’t export session: %1$s
+ Minutes
+ Ignore recent period
+ Recorded sessions
+ No sessions recorded yet.
+ %1$d steps · %2$d stairs · %3$d excluded periods
+ Session comment
+ Legacy session: event export is available, but its strap cannot be identified for sensor export.
From f8e979bb4014628c7b105271e98b2db936d6b6e4 Mon Sep 17 00:00:00 2001
From: Bernd Helm
Date: Mon, 24 Aug 2026 23:42:54 +0200
Subject: [PATCH 08/45] feat(android): export step algorithm signals
---
.../noop/testcentre/GroundTruthCollector.kt | 81 +++++++++++++++++++
1 file changed, 81 insertions(+)
diff --git a/android/app/src/main/java/com/noop/testcentre/GroundTruthCollector.kt b/android/app/src/main/java/com/noop/testcentre/GroundTruthCollector.kt
index 3d9fb64ea8..5e8d2d453f 100644
--- a/android/app/src/main/java/com/noop/testcentre/GroundTruthCollector.kt
+++ b/android/app/src/main/java/com/noop/testcentre/GroundTruthCollector.kt
@@ -262,6 +262,27 @@ class GroundTruthCollector private constructor(private val context: Context) {
out.putNextEntry(ZipEntry("exclusions.csv"))
out.writerEntry(exclusionsCsv(events))
+ out.putNextEntry(ZipEntry("algorithm-signals.csv"))
+ out.bufferedWriterNoClose().use { writer ->
+ writeAlgorithmSignalsCsv(
+ writer,
+ repo,
+ deviceId.orEmpty(),
+ sensorFrom,
+ sensorTo,
+ exclusions,
+ )
+ writer.flush()
+ }
+ out.closeEntry()
+
+ out.putNextEntry(ZipEntry("v18-aux.csv"))
+ out.bufferedWriterNoClose().use { writer ->
+ writeV18AuxCsv(writer, repo, deviceId.orEmpty(), sensorFrom, sensorTo)
+ writer.flush()
+ }
+ out.closeEntry()
+
out.putNextEntry(ZipEntry("raw-sensors.csv"))
out.bufferedWriterNoClose().use { writer ->
RawSensorExport.writeCsv(
@@ -354,6 +375,66 @@ class GroundTruthCollector private constructor(private val context: Context) {
private fun csv(value: String): String = if (value.any { it == ',' || it == '"' || it == '\n' })
"\"${value.replace("\"", "\"\"")}\"" else value
+ private suspend fun writeAlgorithmSignalsCsv(
+ out: java.io.Writer,
+ repo: WhoopRepository,
+ deviceId: String,
+ from: Long,
+ to: Long,
+ exclusions: List>,
+ ) {
+ out.write("unix_s,counter,activity_class,gravity_x,gravity_y,gravity_z,dynamic_accel,cadence,heart_rate,band_sleep_state,excluded\n")
+ if (from > to || deviceId.isBlank()) return
+ val seconds = (to - from + 1L).coerceIn(1L, 86_400L).toInt()
+ val steps = repo.stepSamples(deviceId, from, to, seconds).associateBy { it.ts }
+ val gravity = repo.gravitySamples(deviceId, from, to, seconds).associateBy { it.ts }
+ val cadence = repo.v18AuxSamples(deviceId, from, to, seconds).associate { it.ts to it.stepCadence }
+ val heartRate = repo.rawHrSamples(deviceId, from, to, seconds).associate { it.ts to it.bpm }
+ val sleepState = repo.sleepStateSamples(deviceId, from, to, seconds).associate { it.ts to it.state }
+ val timestamps = (steps.keys + gravity.keys + cadence.keys + heartRate.keys + sleepState.keys).sorted()
+ for (ts in timestamps) {
+ val step = steps[ts]
+ val grav = gravity[ts]
+ val excluded = exclusions.any { ts * 1_000L <= it.second && ts * 1_000L + 999L >= it.first }
+ out.write(listOf(
+ ts,
+ step?.counter ?: "",
+ step?.activityClass ?: "",
+ grav?.x?.let(::decimal) ?: "",
+ grav?.y?.let(::decimal) ?: "",
+ grav?.z?.let(::decimal) ?: "",
+ grav?.dynAccel?.let(::decimal) ?: "",
+ cadence[ts] ?: "",
+ heartRate[ts] ?: "",
+ sleepState[ts] ?: "",
+ excluded,
+ ).joinToString(","))
+ out.write("\n")
+ }
+ }
+
+ private suspend fun writeV18AuxCsv(
+ out: java.io.Writer,
+ repo: WhoopRepository,
+ deviceId: String,
+ from: Long,
+ to: Long,
+ ) {
+ out.write(
+ "unix_s,record_index,rr_count,cardiac_flags,hr_quality_flags,heart_rate_alt,rr_packed," +
+ "cardiac_status,step_cadence,status_word,status_word_1,status_word_2,aux_byte_82," +
+ "optical_baseline_a,optical_baseline_b,optical_amp_a,optical_amp_b,unknown_f32_bits\n",
+ )
+ if (from > to || deviceId.isBlank()) return
+ val seconds = (to - from + 1L).coerceIn(1L, 86_400L).toInt()
+ for (row in repo.v18AuxSamples(deviceId, from, to, seconds)) {
+ out.write((listOf(row.ts) + row.slotValues.map { it ?: "" }).joinToString(","))
+ out.write("\n")
+ }
+ }
+
+ private fun decimal(value: Double): String = String.format(java.util.Locale.US, "%.9f", value)
+
private suspend fun writeRawImuCsv(
out: java.io.Writer,
repo: WhoopRepository,
From fd4eff3db719eb574ce48337de8f45c09b64ac72 Mon Sep 17 00:00:00 2001
From: Bernd Helm
Date: Wed, 26 Aug 2026 17:15:44 +0200
Subject: [PATCH 09/45] Add ground-truth realtime IMU diagnostics
---
.../main/java/com/noop/ble/WhoopBleClient.kt | 112 +++++++++++++++++-
.../src/main/java/com/noop/protocol/Enums.kt | 4 +
.../noop/testcentre/GroundTruthCollector.kt | 10 ++
.../com/noop/ui/GroundTruthCollectorScreen.kt | 83 ++++++++++++-
4 files changed, 207 insertions(+), 2 deletions(-)
diff --git a/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt b/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt
index 94fe81b2ad..33bb1a3977 100644
--- a/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt
+++ b/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt
@@ -90,6 +90,10 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import java.util.UUID
+import java.io.BufferedOutputStream
+import java.io.DataOutputStream
+import java.io.File
+import java.io.FileOutputStream
import java.util.concurrent.ConcurrentLinkedQueue
import java.util.concurrent.atomic.AtomicInteger
@@ -392,6 +396,16 @@ class RealGattOps(private val gatt: BluetoothGatt) : GattOps {
gatt.setPreferredPhy(txPhy, rxPhy, phyOptions)
}
+/** Foreground ground-truth diagnostics. Kept separate from [LiveState] so production UI/state stays clean. */
+data class GroundTruthImuStatus(
+ val sessionId: String? = null,
+ val requested: Boolean = false,
+ val packets: Long = 0,
+ val bytes: Long = 0,
+ val lastPacketAtMs: Long? = null,
+ val note: String = "Not started",
+)
+
class WhoopBleClient(
private val context: Context,
/**
@@ -1763,6 +1777,9 @@ class WhoopBleClient(
)
val state: StateFlow = _state.asStateFlow()
+ private val _groundTruthImuStatus = MutableStateFlow(GroundTruthImuStatus())
+ val groundTruthImuStatus: StateFlow = _groundTruthImuStatus.asStateFlow()
+
/** Monotonic event for the in-memory/exportable strap log. Test Centre panels collect it only while
* active and coalesce bursts, so a new tagged line refreshes its readout without mutating LiveState. */
private val _logRevision = MutableStateFlow(0L)
@@ -2165,6 +2182,96 @@ class WhoopBleClient(
* thread (user actions) - the barrier makes a main-thread send see the current characteristic. */
@Volatile private var cmdCharacteristic: BluetoothGattCharacteristic? = null
+ @Volatile private var groundTruthImuCommandAllowed = false
+ private var groundTruthImuSessionId: String? = null
+ private var groundTruthImuOut: DataOutputStream? = null
+
+ /** Start the hardware-confirmed WHOOP 5 realtime IMU mode for an explicit ground-truth session. */
+ @Synchronized
+ fun startGroundTruthImuCapture(sessionId: String): Boolean {
+ if (gatt == null || cmdCharacteristic == null) {
+ _groundTruthImuStatus.value = GroundTruthImuStatus(
+ sessionId = sessionId,
+ note = "Band not connected / command channel not ready",
+ )
+ return false
+ }
+ if (connectedFamily != DeviceFamily.WHOOP5) {
+ _groundTruthImuStatus.value = GroundTruthImuStatus(
+ sessionId = sessionId,
+ note = "Realtime IMU unsupported for connected family: $connectedFamily",
+ )
+ return false
+ }
+ if (groundTruthImuSessionId != sessionId || groundTruthImuOut == null) {
+ closeGroundTruthImuFile()
+ val dir = File(context.filesDir, "ground-truth").apply { mkdirs() }
+ val file = File(dir, "realtime-imu-$sessionId.bin")
+ // Append lets a foreground collector resume the same session after an Android process restart.
+ groundTruthImuOut = DataOutputStream(BufferedOutputStream(FileOutputStream(file, true), 64 * 1024))
+ groundTruthImuSessionId = sessionId
+ _groundTruthImuStatus.value = GroundTruthImuStatus(sessionId = sessionId, note = "Capture file open")
+ }
+ groundTruthImuCommandAllowed = true
+ try {
+ send(CommandNumber.TOGGLE_IMU_MODE, byteArrayOf(1, 1), withResponse = true)
+ } finally {
+ groundTruthImuCommandAllowed = false
+ }
+ log("Ground-truth realtime IMU requested ON (session=$sessionId)")
+ _groundTruthImuStatus.update { it.copy(requested = true, note = "IMU requested; waiting for packets") }
+ return true
+ }
+
+ /** Stop the realtime IMU before closing its lossless length-prefixed frame file. */
+ @Synchronized
+ fun stopGroundTruthImuCapture() {
+ if (connectedFamily == DeviceFamily.WHOOP5 && gatt != null && cmdCharacteristic != null) {
+ groundTruthImuCommandAllowed = true
+ try {
+ send(CommandNumber.TOGGLE_IMU_MODE, byteArrayOf(1, 0), withResponse = true)
+ } finally {
+ groundTruthImuCommandAllowed = false
+ }
+ }
+ log("Ground-truth realtime IMU requested OFF (session=${groundTruthImuSessionId ?: "none"})")
+ _groundTruthImuStatus.update { it.copy(requested = false, note = "Stopped") }
+ closeGroundTruthImuFile()
+ }
+
+ @Synchronized
+ private fun recordGroundTruthImuFrame(frame: ByteArray) {
+ if (frame.size <= 8 || (frame[8].toInt() and 0xFF) != 51) return
+ groundTruthImuOut?.let { out ->
+ runCatching {
+ out.writeLong(System.currentTimeMillis())
+ out.writeInt(frame.size)
+ out.write(frame)
+ val now = System.currentTimeMillis()
+ _groundTruthImuStatus.update {
+ it.copy(
+ packets = it.packets + 1,
+ bytes = it.bytes + frame.size,
+ lastPacketAtMs = now,
+ note = "Receiving realtime IMU",
+ )
+ }
+ }.onFailure {
+ log("Ground-truth realtime IMU write failed (${it.javaClass.simpleName}); capture stopped")
+ _groundTruthImuStatus.update { status -> status.copy(requested = false, note = "Write failed: ${it.javaClass.simpleName}") }
+ closeGroundTruthImuFile()
+ }
+ }
+ }
+
+ @Synchronized
+ private fun closeGroundTruthImuFile() {
+ runCatching { groundTruthImuOut?.flush() }
+ runCatching { groundTruthImuOut?.close() }
+ groundTruthImuOut = null
+ groundTruthImuSessionId = null
+ }
+
/** Frame reassembler for the fragmented custom notify chars (port of Reassembler). Reassigned per
* connection with the detected family — WHOOP5/MG frames use a different length encoding. */
private var reassembler = Reassembler()
@@ -3448,7 +3555,9 @@ class WhoopBleClient(
// 5/MG allow-list: live HR, buzz, and the historical-offload pair (trigger + ack). The
// offload commands ride the SAME proven puffin COMMAND frame as the Swift path
// (whoop5HistoricalAckFrame = puffinCommandFrame(23, [0x01]+endData)). (#78)
- if (cmd != CommandNumber.TOGGLE_REALTIME_HR && cmd != CommandNumber.RUN_HAPTICS_PATTERN &&
+ if (cmd != CommandNumber.TOGGLE_REALTIME_HR &&
+ !(cmd == CommandNumber.TOGGLE_IMU_MODE && groundTruthImuCommandAllowed) &&
+ cmd != CommandNumber.RUN_HAPTICS_PATTERN &&
cmd != CommandNumber.SEND_HISTORICAL_DATA && cmd != CommandNumber.HISTORICAL_DATA_RESULT &&
// ABORT_HISTORICAL_TRANSMITS (20) over puffin: stop an offload already in flight. Allowed
// ONLY while one actually is, so a default install can never form these bytes on a 5/MG —
@@ -5948,6 +6057,7 @@ class WhoopBleClient(
// A frame replayed as part of the historical offload (type 47/48/… during a backfill)
// must not drive LIVE-only state (the charging pill). Mirrors iOS, where the offload
// path skips the live router entirely. (PR #568 reimpl)
+ recordGroundTruthImuFrame(frame)
handleFrame(frame, parsed, replayedOffload = offloadFrame)
// Capture the strap's newest stored record from a GET_DATA_RANGE reply, feeding
diff --git a/android/app/src/main/java/com/noop/protocol/Enums.kt b/android/app/src/main/java/com/noop/protocol/Enums.kt
index 0e9ef0117c..0339d3b122 100644
--- a/android/app/src/main/java/com/noop/protocol/Enums.kt
+++ b/android/app/src/main/java/com/noop/protocol/Enums.kt
@@ -215,6 +215,10 @@ enum class CommandNumber(val rawValue: Int) {
SET_DEVICE_CONFIG(119),
START_RAW_DATA(81),
STOP_RAW_DATA(82),
+ // WHOOP 5/MG realtime 6-axis IMU. The production WHOOP app sends revision 1 plus an enabled
+ // byte while a Strength Builder workout is active (hardware-captured on a normal WHOOP 5).
+ // NOOP exposes this only through the explicitly user-started Ground Truth collector.
+ TOGGLE_IMU_MODE(106),
// GET_EXTENDED_BATTERY_INFO (98) — read-only extended battery read (mV etc.). The NUMBER is disputed
// (#592): an independent APK decompile reads this family 11 lower (87), while whoomp's table says 98 —
// partially supported by a real WHOOP 5 (fw 50.38.1.0) ANSWERING 98, though with a short stub that
diff --git a/android/app/src/main/java/com/noop/testcentre/GroundTruthCollector.kt b/android/app/src/main/java/com/noop/testcentre/GroundTruthCollector.kt
index 5e8d2d453f..df3e05dfdf 100644
--- a/android/app/src/main/java/com/noop/testcentre/GroundTruthCollector.kt
+++ b/android/app/src/main/java/com/noop/testcentre/GroundTruthCollector.kt
@@ -57,6 +57,9 @@ class GroundTruthCollector private constructor(private val context: Context) {
private val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
private val directory = File(context.filesDir, "ground-truth").apply { mkdirs() }
+ fun realtimeImuBytes(sessionId: String): Long =
+ File(directory, "realtime-imu-$sessionId.bin").takeIf(File::isFile)?.length() ?: 0L
+
@Synchronized
fun snapshot(): Snapshot = Snapshot(
active = prefs.getBoolean("active", false),
@@ -300,6 +303,13 @@ class GroundTruthCollector private constructor(private val context: Context) {
writer.flush()
}
out.closeEntry()
+
+ val realtimeImu = File(directory, "realtime-imu-$id.bin")
+ if (realtimeImu.isFile) {
+ out.putNextEntry(ZipEntry("realtime-imu.bin"))
+ realtimeImu.inputStream().use { it.copyTo(out) }
+ out.closeEntry()
+ }
}
prefs.edit().putBoolean(sessionExportedKey(id), true).apply()
if (id == snap.sessionId) prefs.edit().putBoolean("exported", true).apply()
diff --git a/android/app/src/main/java/com/noop/ui/GroundTruthCollectorScreen.kt b/android/app/src/main/java/com/noop/ui/GroundTruthCollectorScreen.kt
index 1ccc3e6cd9..05f19daf9a 100644
--- a/android/app/src/main/java/com/noop/ui/GroundTruthCollectorScreen.kt
+++ b/android/app/src/main/java/com/noop/ui/GroundTruthCollectorScreen.kt
@@ -37,6 +37,7 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.noop.R
import com.noop.testcentre.GroundTruthCollector
+import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
/** Foreground-only hardware-clicker capture. Production steps never depend on this research surface. */
@@ -49,12 +50,22 @@ fun GroundTruthCollectorScreen(vm: AppViewModel) {
val scope = rememberCoroutineScope()
val cycle by vm.activeDayCycle.collectAsStateWithLifecycle()
val days by vm.recentDays.collectAsStateWithLifecycle()
+ val live by vm.live.collectAsStateWithLifecycle()
+ val imuStatus by vm.ble.groundTruthImuStatus.collectAsStateWithLifecycle()
val noopSteps = cycle?.steps ?: days.lastOrNull()?.steps
var state by remember { mutableStateOf(collector.snapshot()) }
var haptics by remember { mutableStateOf(false) }
var exportingSessionId by remember { mutableStateOf(null) }
var excludeMinutes by remember { mutableStateOf("5") }
var sessions by remember { mutableStateOf(collector.sessions()) }
+ var latestSensorTs by remember { mutableStateOf(null) }
+
+ LaunchedEffect(vm.activeStrapId) {
+ while (true) {
+ latestSensorTs = vm.activeStrapId.takeIf(String::isNotBlank)?.let { vm.repo.latestHrSampleTs(it) }
+ delay(1_000)
+ }
+ }
DisposableEffect(view) {
val oldKeepScreenOn = view.keepScreenOn
@@ -64,6 +75,10 @@ fun GroundTruthCollectorScreen(vm: AppViewModel) {
}
}
LaunchedEffect(state.active, state.steps, state.stairs) { focusRequester.requestFocus() }
+ // Re-arm after a BLE reconnect or Android process restart while the manual session is still active.
+ LaunchedEffect(state.active, state.sessionId, live.connected) {
+ if (state.active && live.connected) state.sessionId?.let(vm.ble::startGroundTruthImuCapture)
+ }
ScreenScaffold(
title = stringResource(R.string.ground_truth_title),
@@ -104,6 +119,39 @@ fun GroundTruthCollectorScreen(vm: AppViewModel) {
}
}
+ NoopCard {
+ Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
+ Text("Capture diagnostics", style = NoopType.headline, color = Palette.textPrimary)
+ Text(
+ if (live.connected) "Band: connected${if (live.bonded) " + paired" else "; pairing"}"
+ else "Band: disconnected${if (live.scanning) "; searching" else ""}",
+ style = NoopType.body,
+ color = if (live.connected) Palette.statusPositive else Palette.statusCritical,
+ )
+ Text(
+ when {
+ live.backfilling -> "History sync: running (${live.syncChunksThisSession} chunks)"
+ live.lastSyncAt != null -> "History sync completed: ${diagnosticTime(live.lastSyncAt!! * 1000)}"
+ else -> "History sync: no completed sync recorded"
+ },
+ style = NoopType.body,
+ color = Palette.textSecondary,
+ )
+ Text(
+ latestSensorTs?.let { "Sensor data through: ${diagnosticTime(it * 1000)} (${diagnosticAge(it * 1000)} behind)" }
+ ?: "Sensor data through: none",
+ style = NoopType.body,
+ color = Palette.textSecondary,
+ )
+ Text(
+ "Realtime IMU: ${imuStatus.note}; ${imuStatus.packets} packets / ${imuStatus.bytes} bytes" +
+ (imuStatus.lastPacketAtMs?.let { "; last ${diagnosticAge(it)} ago" } ?: ""),
+ style = NoopType.body,
+ color = if (imuStatus.packets > 0) Palette.statusPositive else Palette.textSecondary,
+ )
+ }
+ }
+
NoopCard {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(stringResource(R.string.ground_truth_mapping_title), style = NoopType.headline, color = Palette.textPrimary)
@@ -143,7 +191,11 @@ fun GroundTruthCollectorScreen(vm: AppViewModel) {
text = stringResource(R.string.ground_truth_stop),
kind = NoopButtonKind.Destructive,
modifier = Modifier.weight(1f),
- onClick = { state = collector.stop(noopSteps); sessions = collector.sessions() },
+ onClick = {
+ vm.ble.stopGroundTruthImuCapture()
+ state = collector.stop(noopSteps)
+ sessions = collector.sessions()
+ },
)
}
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {
@@ -173,6 +225,7 @@ fun GroundTruthCollectorScreen(vm: AppViewModel) {
enabled = noopSteps != null && vm.activeStrapId.isNotBlank(),
onClick = {
state = collector.start(requireNotNull(noopSteps), vm.activeStrapId)
+ state.sessionId?.let(vm.ble::startGroundTruthImuCapture)
sessions = collector.sessions()
},
)
@@ -184,6 +237,8 @@ fun GroundTruthCollectorScreen(vm: AppViewModel) {
sessions.forEach { session ->
SessionCard(
session = session,
+ latestSensorTs = latestSensorTs,
+ realtimeImuBytes = collector.realtimeImuBytes(session.id),
exporting = exportingSessionId == session.id,
onComment = { comment ->
collector.setComment(session.id, comment)
@@ -213,6 +268,8 @@ fun GroundTruthCollectorScreen(vm: AppViewModel) {
@Composable
private fun SessionCard(
session: GroundTruthCollector.SessionSummary,
+ latestSensorTs: Long?,
+ realtimeImuBytes: Long,
exporting: Boolean,
onComment: (String) -> Unit,
onExport: () -> Unit,
@@ -223,6 +280,18 @@ private fun SessionCard(
}
NoopCard {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
+ val sensorCovered = session.endedAtMs?.let { end -> latestSensorTs?.times(1000)?.let { it >= end } } == true
+ val captureReady = sensorCovered && realtimeImuBytes > 0
+ Text(
+ when {
+ session.endedAtMs == null -> "Export status: recording"
+ captureReady -> "Export status: ready (history covered, IMU ${realtimeImuBytes} bytes)"
+ !sensorCovered -> "Export status: wait for history sync through ${diagnosticTime(requireNotNull(session.endedAtMs))}"
+ else -> "Export status: no realtime IMU packets captured"
+ },
+ style = NoopType.caption,
+ color = if (captureReady) Palette.statusPositive else Palette.statusCritical,
+ )
Text(time, style = NoopType.headline, color = Palette.textPrimary)
Text(
stringResource(
@@ -275,3 +344,15 @@ private fun KeyEvent.toCollectorKey(): GroundTruthCollector.KeyInfo {
source = source,
)
}
+
+private fun diagnosticTime(epochMs: Long): String =
+ java.text.SimpleDateFormat("HH:mm:ss", java.util.Locale.getDefault()).format(java.util.Date(epochMs))
+
+private fun diagnosticAge(epochMs: Long): String {
+ val seconds = ((System.currentTimeMillis() - epochMs).coerceAtLeast(0L) / 1_000L)
+ return when {
+ seconds < 60 -> "${seconds}s"
+ seconds < 3_600 -> "${seconds / 60}m ${seconds % 60}s"
+ else -> "${seconds / 3_600}h ${(seconds % 3_600) / 60}m"
+ }
+}
From 01f2fcac1c3357704b8872134dfa10d265df0893 Mon Sep 17 00:00:00 2001
From: Bernd Helm
Date: Wed, 26 Aug 2026 17:42:13 +0200
Subject: [PATCH 10/45] Start the raw producer for ground-truth IMU capture
---
.../main/java/com/noop/ble/WhoopBleClient.kt | 19 ++++++++++++++++---
.../com/noop/ui/GroundTruthCollectorScreen.kt | 1 -
2 files changed, 16 insertions(+), 4 deletions(-)
diff --git a/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt b/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt
index 33bb1a3977..ec517a6192 100644
--- a/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt
+++ b/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt
@@ -2214,6 +2214,9 @@ class WhoopBleClient(
}
groundTruthImuCommandAllowed = true
try {
+ // The verified bounded raw-capture sequence is START_RAW_DATA followed by the
+ // realtime IMU selector. Opcode 106 alone ACKs but does not start the producer.
+ send(CommandNumber.START_RAW_DATA, byteArrayOf(1), withResponse = true)
send(CommandNumber.TOGGLE_IMU_MODE, byteArrayOf(1, 1), withResponse = true)
} finally {
groundTruthImuCommandAllowed = false
@@ -2229,6 +2232,7 @@ class WhoopBleClient(
if (connectedFamily == DeviceFamily.WHOOP5 && gatt != null && cmdCharacteristic != null) {
groundTruthImuCommandAllowed = true
try {
+ send(CommandNumber.STOP_RAW_DATA, byteArrayOf(1), withResponse = true)
send(CommandNumber.TOGGLE_IMU_MODE, byteArrayOf(1, 0), withResponse = true)
} finally {
groundTruthImuCommandAllowed = false
@@ -2241,7 +2245,12 @@ class WhoopBleClient(
@Synchronized
private fun recordGroundTruthImuFrame(frame: ByteArray) {
- if (frame.size <= 8 || (frame[8].toInt() and 0xFF) != 51) return
+ if (frame.size <= 8) return
+ val packetType = frame[8].toInt() and 0xFF
+ // 51 is the named realtime IMU stream. Firmware/protocol revisions also use the older raw
+ // stream (43) and historical IMU stream (52); retain all three losslessly during an explicit
+ // ground-truth session instead of silently discarding a valid 100 Hz producer.
+ if (packetType !in setOf(43, 51, 52)) return
groundTruthImuOut?.let { out ->
runCatching {
out.writeLong(System.currentTimeMillis())
@@ -2253,7 +2262,7 @@ class WhoopBleClient(
packets = it.packets + 1,
bytes = it.bytes + frame.size,
lastPacketAtMs = now,
- note = "Receiving realtime IMU",
+ note = "Receiving IMU packet type $packetType",
)
}
}.onFailure {
@@ -3556,7 +3565,11 @@ class WhoopBleClient(
// offload commands ride the SAME proven puffin COMMAND frame as the Swift path
// (whoop5HistoricalAckFrame = puffinCommandFrame(23, [0x01]+endData)). (#78)
if (cmd != CommandNumber.TOGGLE_REALTIME_HR &&
- !(cmd == CommandNumber.TOGGLE_IMU_MODE && groundTruthImuCommandAllowed) &&
+ !(cmd in setOf(
+ CommandNumber.START_RAW_DATA,
+ CommandNumber.STOP_RAW_DATA,
+ CommandNumber.TOGGLE_IMU_MODE,
+ ) && groundTruthImuCommandAllowed) &&
cmd != CommandNumber.RUN_HAPTICS_PATTERN &&
cmd != CommandNumber.SEND_HISTORICAL_DATA && cmd != CommandNumber.HISTORICAL_DATA_RESULT &&
// ABORT_HISTORICAL_TRANSMITS (20) over puffin: stop an offload already in flight. Allowed
diff --git a/android/app/src/main/java/com/noop/ui/GroundTruthCollectorScreen.kt b/android/app/src/main/java/com/noop/ui/GroundTruthCollectorScreen.kt
index 05f19daf9a..0d0eb8f6bd 100644
--- a/android/app/src/main/java/com/noop/ui/GroundTruthCollectorScreen.kt
+++ b/android/app/src/main/java/com/noop/ui/GroundTruthCollectorScreen.kt
@@ -225,7 +225,6 @@ fun GroundTruthCollectorScreen(vm: AppViewModel) {
enabled = noopSteps != null && vm.activeStrapId.isNotBlank(),
onClick = {
state = collector.start(requireNotNull(noopSteps), vm.activeStrapId)
- state.sessionId?.let(vm.ble::startGroundTruthImuCapture)
sessions = collector.sessions()
},
)
From 73939af8bf5889fb830633b8f8470b38058af3b4 Mon Sep 17 00:00:00 2001
From: Bernd Helm
Date: Wed, 26 Aug 2026 18:49:57 +0200
Subject: [PATCH 11/45] Build a cross-platform 5/MG raw data collector
---
.../Sources/WhoopProtocol/Whoop5RawImu.swift | 6 +-
Strand/BLE/BLEManager.swift | 19 +++-
Strand/Screens/TestCentreView.swift | 34 +++++++
.../main/java/com/noop/ble/WhoopBleClient.kt | 5 +-
.../noop/testcentre/GroundTruthCollector.kt | 26 +++--
.../main/java/com/noop/ui/AddDeviceWizard.kt | 2 +-
.../com/noop/ui/GroundTruthCollectorScreen.kt | 13 +--
.../main/java/com/noop/ui/SettingsScreen.kt | 82 +---------------
.../main/java/com/noop/ui/TestCentreScreen.kt | 90 +++++++++++++++++
.../app/src/main/res/values-de/strings.xml | 98 +++++++++++++++++++
android/app/src/main/res/values/strings.xml | 16 +--
11 files changed, 274 insertions(+), 117 deletions(-)
diff --git a/Packages/WhoopProtocol/Sources/WhoopProtocol/Whoop5RawImu.swift b/Packages/WhoopProtocol/Sources/WhoopProtocol/Whoop5RawImu.swift
index bd2230957e..b419231782 100644
--- a/Packages/WhoopProtocol/Sources/WhoopProtocol/Whoop5RawImu.swift
+++ b/Packages/WhoopProtocol/Sources/WhoopProtocol/Whoop5RawImu.swift
@@ -8,9 +8,9 @@ import Foundation
// app captures (documented in Asherlc/dofek `docs/whoop-ble-protocol.md` as the type-0x2B raw packet)
// and the same columnar convention as the 4.0's 1917 REALTIME_RAW_DATA variant.
//
-// IMPORTANT: the 5/MG's *live* raw-IMU stream is firmware-refused (TOGGLE_IMU_MODE / cmd 106 acks but
-// never streams), but the OFFLOAD buffer carries full accel AND gyro — so 100 Hz 6-axis IMU IS
-// obtainable on the 5.0 via the historical path.
+// Hardware validation on a WHOOP 5/MG established that live raw IMU requires START_RAW_DATA (81)
+// followed by TOGGLE_IMU_MODE (106) with payload [1,1]. Opcode 106 on its own acknowledges without
+// starting the producer. The same 1244-byte shape is also banked and later returned by history sync.
//
// Frame layout (the reassembled BLE frame = 8-byte puffin envelope + payload; offsets are FRAME-absolute):
// @15 u32 LE strap unix seconds for this 1-second frame
diff --git a/Strand/BLE/BLEManager.swift b/Strand/BLE/BLEManager.swift
index a9db87dbc5..fb841ddb7c 100644
--- a/Strand/BLE/BLEManager.swift
+++ b/Strand/BLE/BLEManager.swift
@@ -1756,8 +1756,14 @@ public final class BLEManager: NSObject, ObservableObject {
rawCaptureInFlight = true
let secs = RawCaptureWindow.clamp(seconds)
collector?.beginRawCapture(seconds: secs)
- send(.startRawData, payload: [0x01])
- send(.toggleIMUMode, payload: [0x01])
+ // Hardware-verified on WHOOP 5/MG: opcode 106 alone acknowledges but does not start the
+ // producer. START_RAW_DATA must precede the two-byte realtime IMU selector.
+ send(.startRawData, payload: [0x01], writeType: .withResponse)
+ if selectedModel.deviceFamily == .whoop5 {
+ send(.toggleIMUMode, payload: [0x01, 0x01], writeType: .withResponse)
+ } else {
+ send(.toggleIMUMode, payload: [0x01], writeType: .withResponse)
+ }
log("Raw-accel capture: started for \(secs)s")
DispatchQueue.main.asyncAfter(deadline: .now() + secs) { [weak self] in
guard let self else { return }
@@ -1765,7 +1771,10 @@ public final class BLEManager: NSObject, ObservableObject {
// continuous stream must keep running — we just flush/upload the bounded window we
// captured without halting the wider session.
if !UserDefaults.standard.bool(forKey: "enableRawCapture") {
- self.send(.stopRawData, payload: [0x01])
+ self.send(.stopRawData, payload: [0x01], writeType: .withResponse)
+ if self.selectedModel.deviceFamily == .whoop5 {
+ self.send(.toggleIMUMode, payload: [0x01, 0x00], writeType: .withResponse)
+ }
}
self.rawCaptureInFlight = false
Task { @MainActor in
@@ -1857,6 +1866,10 @@ public final class BLEManager: NSObject, ObservableObject {
|| (DeviceConfigReadProbe.isReadOnlyOpcode(command.rawValue) && deviceConfigReport != nil)
|| command == .sendHistoricalData || command == .historicalDataResult
|| command == .setClock || command == .getClock
+ // Bounded Raw Data Collector only. These writes remain impossible unless the explicit
+ // user-started capture window is in flight; normal sync never enables this gate.
+ || (rawCaptureInFlight && (command == .startRawData
+ || command == .stopRawData || command == .toggleIMUMode))
// SET_CONFIG / SET_FF_VALUE (120), ENABLE direction — the R22 deep-stream unlock. Allowed
// only while the deep-data experiment is opted in, and only for a KEY and a VALUE the gate
// recognises: one of the sixteen R22 flags carrying that key's own enable value. The clause
diff --git a/Strand/Screens/TestCentreView.swift b/Strand/Screens/TestCentreView.swift
index ec5a195605..4b65a0668c 100644
--- a/Strand/Screens/TestCentreView.swift
+++ b/Strand/Screens/TestCentreView.swift
@@ -35,6 +35,7 @@ struct TestCentreView: View {
// Retention (#650): how many scheduled-export generations to keep, and the manual clear confirm.
@State private var debugExportKeep = ScheduledDebugExport.keepCount
@State private var showClearExportsConfirm = false
+ @State private var rawMotionCaptureActive = false
/// The strap model the user last picked, the same key SettingsView's showFiveMGControls gate reads.
@AppStorage("selectedWhoopModel") private var selectedWhoopModelRaw = WhoopModel.whoop4.rawValue
@@ -77,6 +78,7 @@ struct TestCentreView: View {
VStack(alignment: .leading, spacing: NoopMetrics.sectionSpacing) {
domainModesCard.staggeredAppear(index: 0)
diagnosticToolsCard.staggeredAppear(index: 1)
+ if is5MG { rawDataCollectorCard.staggeredAppear(index: 2) }
exportCard.staggeredAppear(index: 2)
experimentalAlgorithmsCard.staggeredAppear(index: 3)
}
@@ -132,6 +134,38 @@ struct TestCentreView: View {
// MARK: - Section 2: Diagnostic tools (strap log + recalibrate + env dump)
+ @ViewBuilder private var rawDataCollectorCard: some View {
+ NoopCard {
+ VStack(alignment: .leading, spacing: NoopMetrics.space3) {
+ Text("5/MG RAW DATA COLLECTOR")
+ .font(StrandFont.overline).tracking(StrandFont.overlineTracking)
+ .foregroundStyle(StrandPalette.textSecondary)
+ Text("Records a bounded 60-second, 100 Hz motion window. The verified start/stop sequence is used only while this user-started session is active; normal sync is unchanged.")
+ .font(StrandFont.caption).foregroundStyle(StrandPalette.textTertiary)
+ .fixedSize(horizontal: false, vertical: true)
+ NoopButton(
+ rawMotionCaptureActive ? "Capturing 100 Hz motion…" : "Capture 60 seconds",
+ systemImage: rawMotionCaptureActive ? "waveform.path.ecg" : "record.circle",
+ kind: .primary
+ ) {
+ guard !rawMotionCaptureActive else { return }
+ rawMotionCaptureActive = true
+ model.ble.captureRawAccel(seconds: 60)
+ Task { @MainActor in
+ try? await Task.sleep(nanoseconds: 60_000_000_000)
+ rawMotionCaptureActive = false
+ }
+ }
+ .disabled(rawMotionCaptureActive || !live.connected)
+ Text(live.connected
+ ? "The bounded raw window is persisted locally and can be included in Test Centre diagnostics."
+ : "Connect your WHOOP 5/MG to start a capture.")
+ .font(StrandFont.caption)
+ .foregroundStyle(live.connected ? StrandPalette.textSecondary : StrandPalette.statusWarning)
+ }
+ }
+ }
+
@ViewBuilder private var diagnosticToolsCard: some View {
NoopCard {
VStack(alignment: .leading, spacing: NoopMetrics.space3) {
diff --git a/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt b/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt
index ec517a6192..c0f0477a52 100644
--- a/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt
+++ b/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt
@@ -9257,7 +9257,10 @@ class WhoopBleClient(
* 1244-B 6-axis buffer decodes (rawColumns null otherwise). IO-dispatched so it never blocks the GATT
* thread; bounded by a rolling retention prune. Raw i16, no downstream consumer yet (instrument-first). */
private fun storeWhoop5RawImuIfBuffer(frame: ByteArray) {
- if (!PuffinExperiment.from(context).isCaptureEnabled) return
+ // The bounded collector owns its own capture gate. It must not require the unrelated passive
+ // protocol-trace preference: live and history copies then converge on the same keyed raw-IMU
+ // table, whose primary key deduplicates a Bluetooth-gap repair automatically.
+ if (!PuffinExperiment.from(context).isCaptureEnabled && groundTruthImuSessionId == null) return
val cols = Whoop5RawImu.rawColumns(frame) ?: return
val baseTs = PuffinDeepBufferLog.strapTs(frame)?.toLong() ?: return
val dev = deviceId
diff --git a/android/app/src/main/java/com/noop/testcentre/GroundTruthCollector.kt b/android/app/src/main/java/com/noop/testcentre/GroundTruthCollector.kt
index df3e05dfdf..388f97da04 100644
--- a/android/app/src/main/java/com/noop/testcentre/GroundTruthCollector.kt
+++ b/android/app/src/main/java/com/noop/testcentre/GroundTruthCollector.kt
@@ -15,7 +15,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONObject
-/** App-private, explicitly user-started manual ground-truth capture. No production metric reads this. */
+/** App-private, explicitly user-started bounded 5/MG raw-data capture with optional manual labels. */
class GroundTruthCollector private constructor(private val context: Context) {
data class KeyInfo(
val keyCode: Int,
@@ -77,7 +77,7 @@ class GroundTruthCollector private constructor(private val context: Context) {
)
@Synchronized
- fun start(noopSteps: Int, deviceId: String, nowMs: Long = System.currentTimeMillis()): Snapshot {
+ fun start(noopSteps: Int?, deviceId: String, nowMs: Long = System.currentTimeMillis()): Snapshot {
val id = nowMs.toString()
prefs.edit()
.putBoolean("active", true)
@@ -220,22 +220,18 @@ class GroundTruthCollector private constructor(private val context: Context) {
.toList()
}
val endMs = summary.endedAtMs ?: events.lastOrNull()?.optLong("at_ms") ?: summary.startedAtMs
- // Sensor data is useful only around manually marked ground truth. An abandoned collector left
- // open overnight must not turn one tap on Export into a multi-gigabyte query/allocation.
- val markedEvents = events.filter {
- it.optString("kind") == KIND_STEP || it.optString("kind") == KIND_STAIR ||
- it.optString("kind") == KIND_UNDO_STEP || it.optString("kind") == KIND_UNDO_STAIR
- }
val exclusions = exclusionWindows(events)
- val sensorBoundsMs = markedEvents.map { it.optLong("at_ms") } + exclusions.flatMap { listOf(it.first, it.second) }
- val sensorFrom = if (deviceId == null) 1L else sensorBoundsMs.minOrNull()?.div(1_000L)?.minus(5L) ?: 1L
- val sensorTo = if (deviceId == null) 0L else sensorBoundsMs.maxOrNull()?.div(1_000L)?.plus(5L) ?: 0L
+ // A raw-data session is useful even without manual step labels. Export its complete bounded
+ // interval; labels and exclusion windows are optional annotations, not a prerequisite for data.
+ val sensorFrom = if (deviceId == null) 1L else summary.startedAtMs / 1_000L
+ val sensorTo = if (deviceId == null) 0L else endMs / 1_000L
val outDir = File(context.cacheDir, "logs").apply { mkdirs() }
val zip = File(outDir, "noop-ground-truth-$id.zip")
ZipOutputStream(zip.outputStream().buffered()).use { out ->
out.putNextEntry(ZipEntry("meta.json"))
out.writerEntry(JSONObject().apply {
- put("schema_version", 2)
+ put("schema_version", 3)
+ put("capture_kind", "whoop_5mg_raw_data")
put("session_id", id)
if (deviceId != null) put("device_id", deviceId)
put("sensor_export_available", deviceId != null)
@@ -249,7 +245,7 @@ class GroundTruthCollector private constructor(private val context: Context) {
if (id == snap.sessionId && snap.lastNoopSteps != null) put("noop_steps_at_end", snap.lastNoopSteps)
put("device_family", "Android")
put("app_version", BuildConfig.VERSION_NAME)
- put("ground_truth", "JX-05S hardware clicker; ZOOM_OUT=step, ZOOM_IN=stair")
+ put("manual_labels", "optional; JX-05S ZOOM_OUT=step, ZOOM_IN=stair")
}.toString(2))
out.putNextEntry(ZipEntry("events.jsonl"))
@@ -321,9 +317,9 @@ class GroundTruthCollector private constructor(private val context: Context) {
context.startActivity(Intent.createChooser(Intent(Intent.ACTION_SEND).apply {
type = "application/zip"
putExtra(Intent.EXTRA_STREAM, uri)
- putExtra(Intent.EXTRA_SUBJECT, "NOOP ground-truth session")
+ putExtra(Intent.EXTRA_SUBJECT, "NOOP 5/MG raw-data session")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
- }, "Export ground-truth session").addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
+ }, "Export raw-data session").addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
}
private fun eventFile(id: String) = File(directory, "session-$id.jsonl")
diff --git a/android/app/src/main/java/com/noop/ui/AddDeviceWizard.kt b/android/app/src/main/java/com/noop/ui/AddDeviceWizard.kt
index cd97888b81..8253fa1998 100644
--- a/android/app/src/main/java/com/noop/ui/AddDeviceWizard.kt
+++ b/android/app/src/main/java/com/noop/ui/AddDeviceWizard.kt
@@ -700,7 +700,7 @@ private fun ouraHeaderSubtitle(step: OuraStep, advanced: Boolean): String? = whe
@Composable
private fun TypeStep(onPick: (DeviceType) -> Unit) {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
- TypeRow(Icons.Filled.Watch, DeviceType.Whoop5MG.title, "Newer WHOOP band. Experimental in NOOP") {
+ TypeRow(Icons.Filled.Watch, DeviceType.Whoop5MG.title, "Newer WHOOP band with live data and history sync") {
onPick(DeviceType.Whoop5MG)
}
TypeRow(Icons.Filled.Watch, DeviceType.Whoop4.title, "NOOP's primary, fully-supported band") {
diff --git a/android/app/src/main/java/com/noop/ui/GroundTruthCollectorScreen.kt b/android/app/src/main/java/com/noop/ui/GroundTruthCollectorScreen.kt
index 0d0eb8f6bd..eec206d5d7 100644
--- a/android/app/src/main/java/com/noop/ui/GroundTruthCollectorScreen.kt
+++ b/android/app/src/main/java/com/noop/ui/GroundTruthCollectorScreen.kt
@@ -40,7 +40,7 @@ import com.noop.testcentre.GroundTruthCollector
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
-/** Foreground-only hardware-clicker capture. Production steps never depend on this research surface. */
+/** Bounded 5/MG raw-data capture. Manual clicker labels are an optional fork extension. */
@Composable
fun GroundTruthCollectorScreen(vm: AppViewModel) {
val context = LocalContext.current
@@ -48,11 +48,12 @@ fun GroundTruthCollectorScreen(vm: AppViewModel) {
val focusRequester = remember { FocusRequester() }
val view = LocalView.current
val scope = rememberCoroutineScope()
- val cycle by vm.activeDayCycle.collectAsStateWithLifecycle()
val days by vm.recentDays.collectAsStateWithLifecycle()
val live by vm.live.collectAsStateWithLifecycle()
val imuStatus by vm.ble.groundTruthImuStatus.collectAsStateWithLifecycle()
- val noopSteps = cycle?.steps ?: days.lastOrNull()?.steps
+ // Optional fork overlay only. The raw collector itself must not depend on the still-separate
+ // physiological day-cycle work; use the latest published total when the step counter is present.
+ val noopSteps = days.lastOrNull()?.steps
var state by remember { mutableStateOf(collector.snapshot()) }
var haptics by remember { mutableStateOf(false) }
var exportingSessionId by remember { mutableStateOf(null) }
@@ -121,7 +122,7 @@ fun GroundTruthCollectorScreen(vm: AppViewModel) {
NoopCard {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
- Text("Capture diagnostics", style = NoopType.headline, color = Palette.textPrimary)
+ Text("Capture coverage", style = NoopType.headline, color = Palette.textPrimary)
Text(
if (live.connected) "Band: connected${if (live.bonded) " + paired" else "; pairing"}"
else "Band: disconnected${if (live.scanning) "; searching" else ""}",
@@ -222,9 +223,9 @@ fun GroundTruthCollectorScreen(vm: AppViewModel) {
NoopButton(
text = stringResource(R.string.ground_truth_start),
fullWidth = true,
- enabled = noopSteps != null && vm.activeStrapId.isNotBlank(),
+ enabled = vm.activeStrapId.isNotBlank(),
onClick = {
- state = collector.start(requireNotNull(noopSteps), vm.activeStrapId)
+ state = collector.start(noopSteps, vm.activeStrapId)
sessions = collector.sessions()
},
)
diff --git a/android/app/src/main/java/com/noop/ui/SettingsScreen.kt b/android/app/src/main/java/com/noop/ui/SettingsScreen.kt
index 9c26d63161..afefbdd7fa 100644
--- a/android/app/src/main/java/com/noop/ui/SettingsScreen.kt
+++ b/android/app/src/main/java/com/noop/ui/SettingsScreen.kt
@@ -528,8 +528,6 @@ fun SettingsScreen(
// callee's error handling - a throw would strand the button disabled behind a spinner that never
// stops, with no way back short of leaving the screen.
var strapLogBusy by remember { mutableStateOf(false) }
- var whoop5CaptureBusy by remember { mutableStateOf(false) }
- var rawAndLogBusy by remember { mutableStateOf(false) }
// Re-scan must request the runtime Bluetooth permission before scanning — without this the
// button calls connect() directly and silently no-ops on Android 12+ when the permission was
@@ -572,7 +570,6 @@ fun SettingsScreen(
// SharedPreferences isn't reactive, so the Switch drives a local mutableState that the store reads.
val puffinExperiment = remember { PuffinExperiment.from(context) }
var puffinExperiments by remember(rev) { mutableStateOf(puffinExperiment.isEnabled) }
- var puffinCapture by remember(rev) { mutableStateOf(puffinExperiment.isCaptureEnabled) }
var deepData by remember(rev) { mutableStateOf(puffinExperiment.isDeepDataEnabled) }
// #174: set when the deep-data switch is turned OFF, so the app can OFFER to clear the flags on the
@@ -2234,7 +2231,7 @@ fun SettingsScreen(
SettingsCard(
icon = Icons.Filled.Science,
title = uiString(R.string.l10n_settings_screen_experimental_whoop_5_mg_41ef7041),
- blurb = "Live heart rate already works on a WHOOP 5/MG strap. These probes go further and try to coax more out of it. They are guesses, off by default, and only ever touch a 5/MG strap. WHOOP 4.0 is never affected.",
+ blurb = "Normal WHOOP 5/MG recording and history sync are supported. These remaining controls are developer experiments for unmapped protocol features; they are not required for everyday use.",
) {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Row(
@@ -2547,86 +2544,11 @@ fun SettingsScreen(
}
}
- Row(
- modifier = Modifier.fillMaxWidth(),
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(16.dp),
- ) {
- Text(
- uiString(R.string.l10n_settings_screen_record_5_mg_raw_capture_research_1d966bbf),
- style = NoopType.subhead,
- color = Palette.textPrimary,
- modifier = Modifier.weight(1f),
- )
- Switch(
- checked = puffinCapture,
- onCheckedChange = {
- puffinCapture = it
- puffinExperiment.isCaptureEnabled = it
- },
- colors = SwitchDefaults.colors(
- checkedThumbColor = Palette.surfaceBase,
- checkedTrackColor = Palette.accent,
- uncheckedThumbColor = Palette.textSecondary,
- uncheckedTrackColor = Palette.surfaceInset,
- uncheckedBorderColor = Palette.hairline,
- ),
- modifier = Modifier.semantics {
- contentDescription = uiString(R.string.l10n_settings_screen_record_5_mg_raw_capture_9354fe89)
- },
- )
- }
Text(
- uiString(R.string.l10n_settings_screen_records_the_raw_frames_of_each_98a284df),
+ "Raw capture and its protocol-trace exports now live in Test Centre → 5/MG protocol diagnostics.",
style = NoopType.caption,
color = Palette.textTertiary,
)
- NoopButton(
- text = uiString(R.string.l10n_settings_screen_share_5_mg_capture_for_the_e41ac6bd),
- leadingIcon = Icons.Filled.Upload,
- kind = NoopButtonKind.Secondary,
- fullWidth = true,
- enabled = !whoop5CaptureBusy,
- onClick = {
- whoop5CaptureBusy = true
- scope.launch {
- // try/finally: the flag must clear on any exit, not just the happy path (#961 follow-up).
- try {
- LogExport.shareWhoop5Capture(context, live.whoop5Detected, live.encryptedBond)
- } finally {
- whoop5CaptureBusy = false
- }
- }
- },
- )
- if (whoop5CaptureBusy) {
- NoopBusyRow()
- }
-
- // One-tap "matched pair" export (#510): hands a reporter BOTH the raw capture file and
- // the strap log together (timestamped, same minute) so a protocol-mapping issue arrives
- // with the frames AND the context that produced them.
- NoopButton(
- text = uiString(R.string.l10n_settings_screen_export_raw_log_matched_pair_d65390bf),
- leadingIcon = Icons.Filled.IosShare,
- kind = NoopButtonKind.Secondary,
- fullWidth = true,
- enabled = !rawAndLogBusy,
- onClick = {
- rawAndLogBusy = true
- scope.launch {
- // try/finally: the flag must clear on any exit, not just the happy path (#961 follow-up).
- try {
- LogExport.shareRawAndLog(context, vm.ble.exportLogText(), live.whoop5Detected, live.encryptedBond)
- } finally {
- rawAndLogBusy = false
- }
- }
- },
- )
- if (rawAndLogBusy) {
- NoopBusyRow()
- }
}
}
} // end if (showFiveMGControls)
diff --git a/android/app/src/main/java/com/noop/ui/TestCentreScreen.kt b/android/app/src/main/java/com/noop/ui/TestCentreScreen.kt
index 3290a5fe96..eb20620294 100644
--- a/android/app/src/main/java/com/noop/ui/TestCentreScreen.kt
+++ b/android/app/src/main/java/com/noop/ui/TestCentreScreen.kt
@@ -98,6 +98,11 @@ fun TestCentreScreen(vm: AppViewModel, onOpenGroundTruthCollector: () -> Unit =
// Match the Settings `showFiveMGControls` gate exactly: pref OR a live-detected 5/MG this session, so a
// 5/MG connected before its pref is written still sees the experimental block. (SettingsScreen.kt:346.)
val is5MG = selectedModelName == WhoopModel.WHOOP5_MG.name || live.whoop5Detected
+ val puffinExperiment = remember { PuffinExperiment.from(context) }
+ var protocolProbes by remember { mutableStateOf(puffinExperiment.isEnabled) }
+ var passiveRawCapture by remember { mutableStateOf(puffinExperiment.isCaptureEnabled) }
+ var rawCaptureBusy by remember { mutableStateOf(false) }
+ var rawAndLogBusy by remember { mutableStateOf(false) }
// A report awaiting the mandatory review-before-share gate (spec section 12). Non-null shows the
// review dialog; confirming runs TestReportFlow.run.
@@ -196,6 +201,71 @@ fun TestCentreScreen(vm: AppViewModel, onOpenGroundTruthCollector: () -> Unit =
)
}
+ if (is5MG) {
+ SettingsSectionTC(
+ icon = Icons.Filled.Science,
+ title = "5/MG protocol diagnostics",
+ blurb = "Developer tools for protocol research. These are separate from the bounded Raw Data Collector above.",
+ ) {
+ Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
+ DeveloperToggleRow(
+ title = "Protocol probes",
+ detail = "Sends experimental protocol queries. It is not needed for normal WHOOP 5/MG sync, sleep, recovery, or steps.",
+ checked = protocolProbes,
+ onCheckedChange = {
+ protocolProbes = it
+ puffinExperiment.isEnabled = it
+ },
+ )
+ DeveloperToggleRow(
+ title = "Passive history/protocol trace",
+ detail = "Records frames that already arrive during history sync. It does not start IMU or any other sensor and may create large files.",
+ checked = passiveRawCapture,
+ onCheckedChange = {
+ passiveRawCapture = it
+ puffinExperiment.isCaptureEnabled = it
+ },
+ )
+ NoopButton(
+ text = "Share passive raw trace",
+ leadingIcon = Icons.Filled.Upload,
+ kind = NoopButtonKind.Secondary,
+ fullWidth = true,
+ enabled = !rawCaptureBusy,
+ onClick = {
+ rawCaptureBusy = true
+ scope.launch {
+ try {
+ LogExport.shareWhoop5Capture(context, live.whoop5Detected, live.encryptedBond)
+ } finally {
+ rawCaptureBusy = false
+ }
+ }
+ },
+ )
+ NoopButton(
+ text = "Export passive trace + strap log",
+ leadingIcon = Icons.Filled.Upload,
+ kind = NoopButtonKind.Secondary,
+ fullWidth = true,
+ enabled = !rawAndLogBusy,
+ onClick = {
+ rawAndLogBusy = true
+ scope.launch {
+ try {
+ LogExport.shareRawAndLog(
+ context, vm.ble.exportLogText(), live.whoop5Detected, live.encryptedBond,
+ )
+ } finally {
+ rawAndLogBusy = false
+ }
+ }
+ },
+ )
+ }
+ }
+ }
+
// --- Section 3: Export and auto-export ---
ExportCard(
vm = vm,
@@ -244,6 +314,26 @@ fun TestCentreScreen(vm: AppViewModel, onOpenGroundTruthCollector: () -> Unit =
}
}
+@Composable
+private fun DeveloperToggleRow(
+ title: String,
+ detail: String,
+ checked: Boolean,
+ onCheckedChange: (Boolean) -> Unit,
+) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) {
+ Text(title, style = NoopType.subhead, color = Palette.textPrimary)
+ Text(detail, style = NoopType.caption, color = Palette.textSecondary)
+ }
+ Switch(checked = checked, onCheckedChange = onCheckedChange)
+ }
+}
+
/** A report staged for the mandatory review gate: the profile, its title, the already-redacted entries
* and the gate built over them. The Kotlin gate keeps its entries private, so we hold them here too to
* hand TestReportFlow.run the same list it reviews. [modeInactive] (#1002): the selected profile's test
diff --git a/android/app/src/main/res/values-de/strings.xml b/android/app/src/main/res/values-de/strings.xml
index bab45f8753..5a39dd4b46 100644
--- a/android/app/src/main/res/values-de/strings.xml
+++ b/android/app/src/main/res/values-de/strings.xml
@@ -2466,4 +2466,102 @@
Android um Kopplung bitten (experimentell)
NOOP hat bisher darauf gesetzt, dass ein Schreibvorgang an dein 5.0/MG Android zur Kopplung veranlasst. Das passiert nie. Diese Option fragt Android direkt und kann einen System-Kopplungsdialog anzeigen. Die Live-Herzfrequenz funktioniert in beiden Fällen weiter; schalte sie aus, wenn der Dialog stört.
Android um Kopplung bitten
+ 5/MG-Rohdaten-Collector
+ Zeichnet eine begrenzte Bewegungssession mit 100 Hz auf, ergänzt Bluetooth-Lücken aus dem Strap-Verlauf und exportiert die vollständige Zeitachse.
+ Starte und exportiere eine begrenzte 5/MG-Bewegungsaufnahme. Manuelle Schritt- und Stufenmarkierungen sind optional und ändern niemals deine Gesundheitsdaten.
+ Rohdaten-Collector öffnen
+ NOOP-Schritte
+ Manuelle Schritte
+ Stufen
+ NOOP − manuell: %1$s
+ Optionale manuelle Markierungen
+ Für die Schrittalgorithmus-Forschung markiert Verkleinern einen Schritt und Vergrößern eine Stufe plus einen Schritt. Die Rohdatenaufnahme funktioniert ohne Klicker.
+ Drücke eine Hardwaretaste, um ihren Tastencode zu sehen.
+ %1$s · Taste %2$d · Scan %3$d\n%4$s · Gerät %5$d · Quelle 0x%6$x
+ Bei markiertem Klick vibrieren
+ Optionale Bestätigung; standardmäßig aus, um bei langen Sessions Akku zu sparen.
+ Rohdaten-Session starten
+ Letzten Klick rückgängig
+ Session beenden
+ Session exportieren
+ Export wird erstellt…
+ Session konnte nicht exportiert werden: %1$s
+ Minuten
+ Letzten Zeitraum ignorieren
+ Aufgezeichnete Sessions
+ Noch keine Sessions aufgezeichnet.
+ %1$d Schritte · %2$d Stufen · %3$d ausgeschlossene Zeiträume
+ Kommentar zur Session
+ Ältere Session: Ereignisse können exportiert werden, der zugehörige Strap für Sensordaten ist aber nicht mehr eindeutig.
+ Self-hosted Push
+ Self-hosted Push
+ Experimentell · Android · nur WLAN
+ Dein Endpunkt
+ Wenn aktiviert, sendet NOOP rohe Gesundheitsdaten von diesem Telefon an den von dir kontrollierten Endpunkt. Dabei verlassen Daten ausdrücklich das Gerät.
+ Nur Einweg-Export. NOOP kann Daten von diesem Endpunkt nicht wiederherstellen und stellt keinen Empfänger bereit.
+ Endpunkt-URL
+ Bearer-Token (ausgeblendet)
+ Bearer-Token (gespeichert; zur Ersetzung eingeben)
+ Gespeichertes Token löschen
+ Gespeichertes Token gelöscht. Push wurde deaktiviert.
+ Ziel speichern
+ Ziel gespeichert.
+ Verbindung testen
+ Verbindung wird getestet…
+ Prüft Anmeldung und unterstützte Datenarten. Es werden keine Gesundheitsdaten gesendet.
+ Verbinde dich mit einem WLAN, bevor du den Server testest.
+ Der Server ist derzeit nicht erreichbar. Teste die Verbindung später erneut.
+ Der Server hat ungültige oder nicht unterstützte Capabilities geliefert.
+ DNS-Auflösung fehlgeschlagen. Prüfe Hostnamen und DNS-Konfiguration des Endpoints.
+ TLS-Zertifikatsprüfung fehlgeschlagen. Prüfe Hostname, Zertifikatskette und Ablaufdatum.
+ TLS-Handshake fehlgeschlagen. Prüfe Reverse Proxy, TLS-Versionen und Cipher-Konfiguration.
+ Netzwerk-Timeout vor Abschluss der Anfrage. Prüfe Erreichbarkeit und Proxy-Timeouts.
+ Verbindung abgelehnt. Prüfe, ob Receiver und Proxy beziehungsweise Backend-Port laufen und lauschen.
+ Keine Netzwerkroute verfügbar. Prüfe WLAN, Routing und Firewall-Regeln.
+ Verbindung wurde zurückgesetzt. Prüfe Neustarts und Verbindungs-Timeouts von Receiver oder Proxy.
+ Netzwerk-E/A fehlgeschlagen, bevor eine dauerhafte Bestätigung empfangen wurde.
+ HTTP %1$d: Anmeldung abgelehnt. Prüfe Bearer-Token und Weiterleitung des Authorization-Headers im Proxy.
+ HTTP %1$d: Endpoint-Pfad nicht gefunden. Prüfe vollständige URL und Reverse-Proxy-Route.
+ HTTP %1$d: Receiver oder Proxy hat bei der Verarbeitung das Zeitlimit überschritten.
+ HTTP %1$d: Anfrage zu groß. Gleiche Body-Limits von Receiver und Reverse Proxy mit Protokoll 1.0 ab.
+ HTTP %1$d: Der Receiver hat sowohl gzip- als auch unkomprimierte NDJSON-Uploads abgelehnt.
+ HTTP %1$d: Der Receiver hat den Protokoll-Batch abgelehnt. Prüfe v1-Schema, Stream-Allowlist und Serverlogs.
+ HTTP %1$d: Rate-Limit des Receivers erreicht. Automatische Wiederholung nutzt Backoff.
+ HTTP %1$d: Fehler im Receiver oder Reverse Proxy. Prüfe Serverlogs und Health-Status.
+ HTTP %1$d: Unerwarteter Clientfehler. Prüfe Endpoint- und Reverse-Proxy-Konfiguration.
+ Die Capability-Antwort ist fehlerhaft oder entspricht nicht Protokoll 1.0.
+ Die Bestätigung des Receivers ist fehlerhaft oder passt nicht zum gesendeten Batch. Der Cursor wurde nicht vorgerückt.
+ Ein lokaler Datensatz überschreitet Protokollgrenzen oder lässt sich nicht exakt kodieren. Kein Cursor wurde vorgerückt.
+ Lokale Datenbank oder Push-Fortschritt konnten nicht gelesen oder gespeichert werden.
+ Warte auf WLAN.
+ %1$s Eine automatische Wiederholung ist eingeplant.
+ %1$s Automatische Wiederholungen sind pausiert. Behebe die Ursache und nutze dann „Jetzt exportieren“ oder synchronisiere den Strap erneut.
+ Der gespeicherte Bearer-Token ist nicht verfügbar. Speichere ihn erneut.
+ Der Export-Worker konnte nicht sicher gestartet werden.
+ Der nächste begrenzte Exportschritt konnte nicht eingereiht werden.
+ Server unterstützt %1$d von %2$d Datenarten
+ Zuletzt geprüft: %1$s
+ Dieser Server akzeptiert derzeit keine Gesundheitsdaten aus Protokoll 1.0.
+ Akzeptierte Daten: %1$s
+ Automatischen Export aktivieren
+ Exportiert neue und geänderte Daten nach vollständiger Strap-Synchronisierung und holt Rückstände beim App-Start nach. Nur über WLAN.
+ Jetzt exportieren
+ Startet jetzt einen Abgleich. Der automatische Export muss aktiviert sein.
+ Speichere vor dem Aktivieren einen gültigen Endpunkt und ein Token.
+ Status
+ Zugangsdaten erscheinen nie im Status oder in Worker-Metadaten.
+ Nie
+ Letzter vollständiger Abgleich: %1$s
+ Letzter Fehler: %1$s
+ Aktueller Status: %1$s
+ Aktueller Export: %1$d Batches · %2$d Datensätze akzeptiert
+ Aktuelle Datenart: %1$s
+ Inaktiv
+ Eingeplant
+ Wird gesendet
+ Weitere lokale Daten gefunden; wird fortgesetzt
+ Wiederholung nach einem Fehler
+ Aktuell
+ Nach einem Fehler pausiert
+ Experimenteller Einweg-Export an einen von dir kontrollierten Endpunkt. Standardmäßig aus.
diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml
index 883a7369c4..6635a8bbfc 100644
--- a/android/app/src/main/res/values/strings.xml
+++ b/android/app/src/main/res/values/strings.xml
@@ -1600,7 +1600,7 @@
Whole night is NOOP\'s default measure; Deep sleep pools HRV over slow-wave sleep only, reading lower and matching WHOOP. Switching re-scores your recent nights over the new window and takes effect right away once you have a few nights of data.
WHOOP 4.0 versus 5.0: what each can read and why
WHOOP 4.0 vs 5.0/MG
- WHOOP 5/MG straps hand a fresh app only live heart rate. The official app switches on the deeper streams (high-rate HR + motion + history) by writing a set of feature flags, a sequence two independent projects have documented. With this on, the button below sends that exact sequence to your strap. Unlike everything else here it does write to the strap, but it\'s reversible (it only changes which data the strap emits) and is the same thing the official app does. Experimental: it may do nothing on your firmware.
+ Legacy R22 feature-flag experiment. The strap accepts these writes, but NOOP has not observed them enabling a separate live stream. This is not required for normal WHOOP 5/MG support or for the Raw Data Collector. It writes persistent strap settings and may do nothing on your firmware.
WHOOP
Working…
You\'re on the latest (%1$s).
@@ -2478,21 +2478,21 @@
Your strap reports a blood oxygen estimate, but it is unverified and off by default. Turn on the strap estimate in Settings to see it, or import a WHOOP export in Data Sources for the calibrated value.
No blood oxygen percentage from a WHOOP 4.0
Your strap banks the raw optical channels, but NOOP does not turn them into a blood oxygen percentage, so this stays empty however long you wear it. Import a WHOOP export in Data Sources to fill it from your account history.
- Ground-truth collector
- Count steps and stairs with a hardware button while NOOP records the matching sensor timeline.
- A foreground hardware-clicker session for step-algorithm research. It never changes your health data.
- Open collector
+ 5/MG Raw Data Collector
+ Record a bounded 100 Hz motion session, recover Bluetooth gaps from strap history, and export the complete timeline.
+ Start and export a bounded 5/MG motion capture. Manual step and stair labels are optional and never change your health data.
+ Open raw-data collector
NOOP steps
Manual steps
Stairs
NOOP − manual: %1$s
- Hardware mapping
- Zoom Out adds one step. Zoom In adds one stair and one step. Every other key is shown and logged without changing a count.
+ Optional manual labels
+ For step-algorithm research, Zoom Out labels one step and Zoom In labels one stair plus one step. Raw capture works without a clicker.
Press a hardware button to inspect its key code.
%1$s · key %2$d · scan %3$d\n%4$s · device %5$d · source 0x%6$x
Vibrate on a counted press
Optional confirmation; disabled by default to save battery during long sessions.
- Start new session
+ Start raw-data session
Undo last click
Stop session
Export session
From 40f7f6387cd3bb037d25abcd24ec09809e0121aa Mon Sep 17 00:00:00 2001
From: Bernd Helm
Date: Wed, 26 Aug 2026 18:58:11 +0200
Subject: [PATCH 12/45] Move 5/MG developer controls into Test Centre
---
Strand/Screens/AddDeviceWizard.swift | 4 +-
Strand/Screens/SettingsView.swift | 13 +--
Strand/Screens/TestCentreView.swift | 82 +++++++++++++++++++
.../main/java/com/noop/ui/SettingsScreen.kt | 4 +-
.../main/java/com/noop/ui/TestCentreScreen.kt | 82 +++++++++++++++++++
5 files changed, 176 insertions(+), 9 deletions(-)
diff --git a/Strand/Screens/AddDeviceWizard.swift b/Strand/Screens/AddDeviceWizard.swift
index d885560651..6f70fc91dc 100644
--- a/Strand/Screens/AddDeviceWizard.swift
+++ b/Strand/Screens/AddDeviceWizard.swift
@@ -327,7 +327,7 @@ struct AddDeviceWizard: View {
VStack(alignment: .leading, spacing: 10) {
typeRow(.whoop5mg, icon: "applewatch.side.right",
title: "WHOOP 5.0 / MG",
- subtitle: String(localized: "Newer WHOOP band, experimental in NOOP"))
+ subtitle: String(localized: "Newer WHOOP band with live data and history sync"))
typeRow(.whoop4, icon: "applewatch.side.right",
title: "WHOOP 4.0",
subtitle: String(localized: "NOOP's primary, fully-supported band"))
@@ -1430,7 +1430,7 @@ struct AddDeviceWizard: View {
Image(systemName: "flask")
.foregroundStyle(StrandPalette.statusWarning)
.accessibilityHidden(true)
- Text("WHOOP 5.0 / MG support is newer and still experimental in NOOP.")
+ Text("WHOOP 5.0 / MG supports live data and strap history. Protocol-research tools are available separately in Test Centre.")
.font(StrandFont.footnote)
.foregroundStyle(StrandPalette.statusWarning)
.fixedSize(horizontal: false, vertical: true)
diff --git a/Strand/Screens/SettingsView.swift b/Strand/Screens/SettingsView.swift
index 7c76d02b5a..14026173ba 100644
--- a/Strand/Screens/SettingsView.swift
+++ b/Strand/Screens/SettingsView.swift
@@ -1724,7 +1724,8 @@ struct SettingsView: View {
@ViewBuilder private var experimentalCard: some View {
liquidTodayCard
liveSessionsCard
- if showFiveMGControls { fiveMGCard }
+ // WHOOP 5/MG protocol research now lives in Test Centre. Everyday Settings no longer carries
+ // a second copy; the persisted keys and reversible disable actions remain unchanged there.
if showFiveMGControls || model.repo.activeDeviceIsOura { spo2CandidateCard }
sleepStagingCard
rawSensorDiagnosticsCard
@@ -1881,7 +1882,7 @@ struct SettingsView: View {
SettingsSection(
icon: "flask.fill",
title: "Experimental · WHOOP 5 / MG",
- blurb: "Live heart rate already works on a WHOOP 5/MG strap. These probes go further and try to coax more out of it. They are guesses, off by default, and only ever touch a 5/MG strap. WHOOP 4.0 is never affected."
+ blurb: "Normal WHOOP 5/MG recording and history sync are supported. These remaining controls are developer experiments for unmapped protocol features and now live in Test Centre."
) {
VStack(alignment: .leading, spacing: NoopMetrics.rowSpacing) {
Toggle(isOn: $puffinExperiments) {
@@ -1910,7 +1911,7 @@ struct SettingsView: View {
// strap kept every flag the enable sequence set while the UI implied it had been undone.
// Now it offers the real undo. Turning it ON still writes nothing until the button is tapped.
.onChangeCompat(of: deepDataEnabled) { on in if !on { confirmingDeepDataDisable = true } }
- Text("WHOOP 5/MG straps hand a fresh app only live heart rate. The official app switches on the deeper streams (high-rate HR + motion + history) by writing a set of feature flags, a sequence two independent projects have documented. With this on, the button below sends that exact sequence to your strap. Unlike everything else here it does write to the strap — and it is reversible: \u{201C}Turn deep data back off\u{201D} writes the off value to the same flags and then reads every one of them back, so you see what the strap actually stores rather than just that it acked. Experimental: it may do nothing on your firmware. iPhone/Android only. A Mac can't write to a 5/MG.")
+ Text("Legacy R22 feature-flag experiment. The strap accepts these writes, but NOOP has not observed them enabling a separate live stream. This is not required for normal WHOOP 5/MG support or for the Raw Data Collector. It writes persistent strap settings and may do nothing on your firmware.")
.font(StrandFont.caption)
.foregroundStyle(StrandPalette.textTertiary)
.fixedSize(horizontal: false, vertical: true)
@@ -3358,7 +3359,7 @@ struct StepsCalibrationSheet: View {
.font(StrandFont.headline)
.foregroundStyle(StrandPalette.textPrimary)
Text(is5MG
- ? String(localized: "NOOP estimates your steps from your WHOOP's motion, calibrated to your phone's step count. It's an estimate, not a step counter — a WHOOP 5.0 / MG streams motion (not a step count) only with deep data on.")
+ ? String(localized: "NOOP estimates your steps from your WHOOP's stored motion, calibrated to your phone's step count. It's an estimate, not a hardware step counter; normal WHOOP 5/MG history sync supplies the motion data.")
: String(localized: "NOOP estimates your steps from your WHOOP's motion, calibrated to your phone's step count. It's an estimate, not a step counter. A WHOOP 4.0 doesn't transmit steps."))
.font(StrandFont.subhead)
.foregroundStyle(StrandPalette.textSecondary)
@@ -3399,7 +3400,7 @@ struct StepsCalibrationSheet: View {
/// The "why it's empty" line — a 5/MG needs the deep-data unlock before it streams motion at all.
private var noMotionLead: String {
if is5MG {
- return String(localized: "We're not seeing any motion from your WHOOP 5.0 / MG yet. Unlike a 4.0, a 5/MG only streams motion (and history) once the experimental deep-data unlock is on — so until then there's nothing to estimate steps from. Importing history from WHOOP or Apple Health doesn't provide the strap motion this needs.")
+ return String(localized: "We're not seeing motion from your WHOOP 5.0 / MG yet. Keep NOOP connected and let strap history finish syncing; the experimental R22 flags are not required. Account or Apple Health imports do not contain the raw strap motion this estimate needs.")
}
return String(localized: "We're not seeing any motion from your strap yet. Steps are estimated from your WHOOP's banked motion history, so your strap needs to sync that history before NOOP has anything to count.")
}
@@ -3407,7 +3408,7 @@ struct StepsCalibrationSheet: View {
/// The "what to do" line — 5/MG points at the deep-data toggle (unless it's already on, then just sync).
private var noMotionAction: String {
if is5MG && !deepDataEnabled {
- return String(localized: "Turn on Settings → \u{201C}Unlock WHOOP 5/MG deep data (R22)\u{201D}, reconnect your strap, then open NOOP near it and let a day or two of motion sync. Your step estimate and the calibration below fill in once motion lands.")
+ return String(localized: "Open NOOP near the strap and let WHOOP 5/MG history finish syncing. The step estimate and calibration fill in once enough stored motion has arrived; the legacy R22 experiment is not required.")
}
if is5MG {
return String(localized: "Deep data is on — open NOOP near your strap and let it sync its motion history (a full first-run sync can take a while). Once a day or two of motion lands, your step estimate and the calibration below fill in.")
diff --git a/Strand/Screens/TestCentreView.swift b/Strand/Screens/TestCentreView.swift
index 4b65a0668c..8a7ead9892 100644
--- a/Strand/Screens/TestCentreView.swift
+++ b/Strand/Screens/TestCentreView.swift
@@ -4,6 +4,7 @@ import StrandAnalytics
import StrandImport
import PolarProtocol
import WhoopStore
+import WhoopProtocol
/// Settings -> Test Centre. The single home for every diagnostic, log and test control (spec section 7).
///
@@ -37,6 +38,14 @@ struct TestCentreView: View {
@State private var showClearExportsConfirm = false
@State private var rawMotionCaptureActive = false
+ // WHOOP 5/MG developer controls. These use the same persisted keys as the former Settings card;
+ // moving the UI does not reset an opt-in or lose the ability to undo a persistent strap write.
+ @AppStorage(PuffinExperiment.defaultsKey) private var puffinExperiments = false
+ @AppStorage(PuffinFrameRecorder.enabledKey) private var puffinCapture = false
+ @AppStorage(PuffinExperiment.deepDataKey) private var deepDataEnabled = false
+ @AppStorage(PuffinExperiment.broadcastHrKey) private var broadcastHrEnabled = false
+ @AppStorage(PuffinExperiment.ecgRawDataKey) private var ecgRawDataEnabled = false
+
/// The strap model the user last picked, the same key SettingsView's showFiveMGControls gate reads.
@AppStorage("selectedWhoopModel") private var selectedWhoopModelRaw = WhoopModel.whoop4.rawValue
@@ -79,6 +88,7 @@ struct TestCentreView: View {
domainModesCard.staggeredAppear(index: 0)
diagnosticToolsCard.staggeredAppear(index: 1)
if is5MG { rawDataCollectorCard.staggeredAppear(index: 2) }
+ if is5MG { fiveMGProtocolDiagnosticsCard.staggeredAppear(index: 3) }
exportCard.staggeredAppear(index: 2)
experimentalAlgorithmsCard.staggeredAppear(index: 3)
}
@@ -166,6 +176,78 @@ struct TestCentreView: View {
}
}
+ @ViewBuilder private var fiveMGProtocolDiagnosticsCard: some View {
+ NoopCard {
+ VStack(alignment: .leading, spacing: NoopMetrics.space3) {
+ Text("5/MG PROTOCOL DIAGNOSTICS")
+ .font(StrandFont.overline).tracking(StrandFont.overlineTracking)
+ .foregroundStyle(StrandPalette.textSecondary)
+ Text("Developer tools for unmapped protocol features. None of these are required for normal WHOOP 5/MG recording, history sync, or the bounded Raw Data Collector.")
+ .font(StrandFont.caption).foregroundStyle(StrandPalette.textTertiary)
+ .fixedSize(horizontal: false, vertical: true)
+
+ Toggle("Protocol probes", isOn: $puffinExperiments)
+ .toggleStyle(.switch).tint(StrandPalette.accent)
+ Text("Sends experimental protocol queries and records the replies in the strap log.")
+ .font(StrandFont.caption).foregroundStyle(StrandPalette.textTertiary)
+
+ Divider().overlay(StrandPalette.hairline)
+ Toggle("Broadcast heart rate from the strap", isOn: $broadcastHrEnabled)
+ .toggleStyle(.switch).tint(StrandPalette.accent)
+ .onChangeCompat(of: broadcastHrEnabled) { model.ble.setBroadcastHr($0) }
+ Text("Writes the reversible 5/MG advertising flag for Garmin, Zwift, and compatible gym equipment.")
+ .font(StrandFont.caption).foregroundStyle(StrandPalette.textTertiary)
+
+ Divider().overlay(StrandPalette.hairline)
+ Toggle("Legacy R22 feature-flag experiment", isOn: $deepDataEnabled)
+ .toggleStyle(.switch).tint(StrandPalette.accent)
+ Text("The strap accepts these writes, but NOOP has not observed them enabling a separate live stream. This is not the Raw Data Collector.")
+ .font(StrandFont.caption).foregroundStyle(StrandPalette.textTertiary)
+ if deepDataEnabled {
+ NoopButton("Send legacy R22 enable sequence", systemImage: "bolt.badge.automatic", kind: .secondary) {
+ model.ble.enableWhoop5DeepData()
+ }
+ .disabled(!live.encryptedBond || !live.worn)
+ }
+ NoopButton("Clear legacy R22 flags on strap", systemImage: "bolt.slash", kind: .secondary) {
+ model.ble.disableWhoop5DeepData()
+ }
+ .disabled(!live.encryptedBond || live.r22DisableReport == BLEManager.deviceConfigProbeWaiting)
+ if let result = live.r22DisableReport {
+ Text(result).font(StrandFont.caption).foregroundStyle(StrandPalette.textSecondary)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+
+ Divider().overlay(StrandPalette.hairline)
+ Toggle("WHOOP MG ECG raw-data gate", isOn: $ecgRawDataEnabled)
+ .toggleStyle(.switch).tint(StrandPalette.accent)
+ Text("MG-only protocol instrumentation, not a medical ECG feature.")
+ .font(StrandFont.caption).foregroundStyle(StrandPalette.textTertiary)
+ if ecgRawDataEnabled {
+ HStack(spacing: NoopMetrics.space3) {
+ NoopButton("Gate on", systemImage: "waveform.path.ecg", kind: .secondary) {
+ model.ble.setEcgRawDataGate(true)
+ }
+ NoopButton("Gate off", systemImage: "arrow.uturn.backward", kind: .secondary) {
+ model.ble.setEcgRawDataGate(false)
+ }
+ }
+ .disabled(!live.encryptedBond || live.whoop5Variant != Whoop5Variant.mg.label)
+ if let result = live.ecgRawDataGate {
+ Text(result.summary).font(StrandFont.caption).foregroundStyle(StrandPalette.textSecondary)
+ }
+ }
+
+ Divider().overlay(StrandPalette.hairline)
+ Toggle("Passive history/protocol trace", isOn: $puffinCapture)
+ .toggleStyle(.switch).tint(StrandPalette.accent)
+ Text("Records frames that already arrive. It does not start sensors and can create large files. Use the export section below to save the trace with its strap log.")
+ .font(StrandFont.caption).foregroundStyle(StrandPalette.textTertiary)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+ }
+ }
+
@ViewBuilder private var diagnosticToolsCard: some View {
NoopCard {
VStack(alignment: .leading, spacing: NoopMetrics.space3) {
diff --git a/android/app/src/main/java/com/noop/ui/SettingsScreen.kt b/android/app/src/main/java/com/noop/ui/SettingsScreen.kt
index afefbdd7fa..8c7057c4c7 100644
--- a/android/app/src/main/java/com/noop/ui/SettingsScreen.kt
+++ b/android/app/src/main/java/com/noop/ui/SettingsScreen.kt
@@ -2227,7 +2227,9 @@ fun SettingsScreen(
) {
Column(verticalArrangement = Arrangement.spacedBy(Metrics.screenRowSpacing)) {
// --- Experimental · WHOOP 5 / MG --- (hidden when the user is confidently on a 4.0, #22)
- if (showFiveMGControls) {
+ // Developer-only 5/MG controls now live in Test Centre. Keep the implementation below during
+ // the compatibility transition, but never render a second copy in everyday Settings.
+ if (false && showFiveMGControls) {
SettingsCard(
icon = Icons.Filled.Science,
title = uiString(R.string.l10n_settings_screen_experimental_whoop_5_mg_41ef7041),
diff --git a/android/app/src/main/java/com/noop/ui/TestCentreScreen.kt b/android/app/src/main/java/com/noop/ui/TestCentreScreen.kt
index eb20620294..fefd6aa646 100644
--- a/android/app/src/main/java/com/noop/ui/TestCentreScreen.kt
+++ b/android/app/src/main/java/com/noop/ui/TestCentreScreen.kt
@@ -101,6 +101,13 @@ fun TestCentreScreen(vm: AppViewModel, onOpenGroundTruthCollector: () -> Unit =
val puffinExperiment = remember { PuffinExperiment.from(context) }
var protocolProbes by remember { mutableStateOf(puffinExperiment.isEnabled) }
var passiveRawCapture by remember { mutableStateOf(puffinExperiment.isCaptureEnabled) }
+ var deepData by remember { mutableStateOf(puffinExperiment.isDeepDataEnabled) }
+ var broadcastHr by remember { mutableStateOf(puffinExperiment.broadcastHr) }
+ var explicitBond by remember { mutableStateOf(puffinExperiment.explicitBond) }
+ var ecgRawData by remember { mutableStateOf(puffinExperiment.ecgRawData) }
+ val r22DisableReport by vm.ble.r22DisableReport.collectAsStateWithLifecycle()
+ val ecgGateReport by vm.ble.ecgRawDataGate.collectAsStateWithLifecycle()
+ val ecgVariant by vm.ble.whoop5VariantFlow.collectAsStateWithLifecycle()
var rawCaptureBusy by remember { mutableStateOf(false) }
var rawAndLogBusy by remember { mutableStateOf(false) }
@@ -217,6 +224,81 @@ fun TestCentreScreen(vm: AppViewModel, onOpenGroundTruthCollector: () -> Unit =
puffinExperiment.isEnabled = it
},
)
+ DeveloperToggleRow(
+ title = "Broadcast heart rate from the strap",
+ detail = "Writes the reversible WHOOP 5/MG advertising flag for Garmin, Zwift, and gym equipment.",
+ checked = broadcastHr,
+ onCheckedChange = {
+ broadcastHr = it
+ puffinExperiment.broadcastHr = it
+ vm.ble.setBroadcastHr(it)
+ },
+ )
+ DeveloperToggleRow(
+ title = "Ask Android to pair",
+ detail = "Experimental explicit Android bonding. Normal 5/MG support does not require this switch.",
+ checked = explicitBond,
+ onCheckedChange = {
+ explicitBond = it
+ puffinExperiment.explicitBond = it
+ },
+ )
+ DeveloperToggleRow(
+ title = "Legacy R22 feature-flag experiment",
+ detail = "Accepted writes have not been shown to enable a separate live stream. Not required for normal sync or raw capture.",
+ checked = deepData,
+ onCheckedChange = {
+ deepData = it
+ puffinExperiment.isDeepDataEnabled = it
+ },
+ )
+ if (deepData) {
+ NoopButton(
+ text = "Send legacy R22 enable sequence",
+ kind = NoopButtonKind.Secondary,
+ fullWidth = true,
+ enabled = live.encryptedBond && live.worn,
+ onClick = { vm.ble.enableWhoop5DeepData() },
+ )
+ }
+ NoopButton(
+ text = "Clear legacy R22 flags on strap",
+ kind = NoopButtonKind.Secondary,
+ fullWidth = true,
+ enabled = live.encryptedBond && r22DisableReport != WhoopBleClient.WAITING_DEVICE_CONFIG_PROBE,
+ onClick = { vm.ble.disableWhoop5DeepData() },
+ )
+ r22DisableReport?.let {
+ Text(it, style = NoopType.caption, color = Palette.textSecondary)
+ }
+ DeveloperToggleRow(
+ title = "WHOOP MG ECG raw-data gate",
+ detail = "MG-only protocol research. This is instrumentation, not a medical ECG feature.",
+ checked = ecgRawData,
+ onCheckedChange = {
+ ecgRawData = it
+ puffinExperiment.ecgRawData = it
+ },
+ )
+ if (ecgRawData) {
+ Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
+ NoopButton(
+ text = "Turn ECG gate on",
+ kind = NoopButtonKind.Secondary,
+ enabled = live.bonded && ecgVariant.isMG,
+ onClick = { vm.ble.setEcgRawDataGate(true) },
+ )
+ NoopButton(
+ text = "Turn ECG gate off",
+ kind = NoopButtonKind.Secondary,
+ enabled = live.bonded && ecgVariant.isMG,
+ onClick = { vm.ble.setEcgRawDataGate(false) },
+ )
+ }
+ ecgGateReport?.let {
+ Text(it.summary, style = NoopType.caption, color = Palette.textSecondary)
+ }
+ }
DeveloperToggleRow(
title = "Passive history/protocol trace",
detail = "Records frames that already arrive during history sync. It does not start IMU or any other sensor and may create large files.",
From 2504ad3a9e45c9b61f3631f3a08e8db81911fa0e Mon Sep 17 00:00:00 2001
From: Bernd Helm
Date: Wed, 26 Aug 2026 19:13:33 +0200
Subject: [PATCH 13/45] Remove unrelated sync translations from collector
branch
---
.../app/src/main/res/values-de/strings.xml | 71 -------------------
1 file changed, 71 deletions(-)
diff --git a/android/app/src/main/res/values-de/strings.xml b/android/app/src/main/res/values-de/strings.xml
index 5a39dd4b46..d15916427a 100644
--- a/android/app/src/main/res/values-de/strings.xml
+++ b/android/app/src/main/res/values-de/strings.xml
@@ -2493,75 +2493,4 @@
%1$d Schritte · %2$d Stufen · %3$d ausgeschlossene Zeiträume
Kommentar zur Session
Ältere Session: Ereignisse können exportiert werden, der zugehörige Strap für Sensordaten ist aber nicht mehr eindeutig.
- Self-hosted Push
- Self-hosted Push
- Experimentell · Android · nur WLAN
- Dein Endpunkt
- Wenn aktiviert, sendet NOOP rohe Gesundheitsdaten von diesem Telefon an den von dir kontrollierten Endpunkt. Dabei verlassen Daten ausdrücklich das Gerät.
- Nur Einweg-Export. NOOP kann Daten von diesem Endpunkt nicht wiederherstellen und stellt keinen Empfänger bereit.
- Endpunkt-URL
- Bearer-Token (ausgeblendet)
- Bearer-Token (gespeichert; zur Ersetzung eingeben)
- Gespeichertes Token löschen
- Gespeichertes Token gelöscht. Push wurde deaktiviert.
- Ziel speichern
- Ziel gespeichert.
- Verbindung testen
- Verbindung wird getestet…
- Prüft Anmeldung und unterstützte Datenarten. Es werden keine Gesundheitsdaten gesendet.
- Verbinde dich mit einem WLAN, bevor du den Server testest.
- Der Server ist derzeit nicht erreichbar. Teste die Verbindung später erneut.
- Der Server hat ungültige oder nicht unterstützte Capabilities geliefert.
- DNS-Auflösung fehlgeschlagen. Prüfe Hostnamen und DNS-Konfiguration des Endpoints.
- TLS-Zertifikatsprüfung fehlgeschlagen. Prüfe Hostname, Zertifikatskette und Ablaufdatum.
- TLS-Handshake fehlgeschlagen. Prüfe Reverse Proxy, TLS-Versionen und Cipher-Konfiguration.
- Netzwerk-Timeout vor Abschluss der Anfrage. Prüfe Erreichbarkeit und Proxy-Timeouts.
- Verbindung abgelehnt. Prüfe, ob Receiver und Proxy beziehungsweise Backend-Port laufen und lauschen.
- Keine Netzwerkroute verfügbar. Prüfe WLAN, Routing und Firewall-Regeln.
- Verbindung wurde zurückgesetzt. Prüfe Neustarts und Verbindungs-Timeouts von Receiver oder Proxy.
- Netzwerk-E/A fehlgeschlagen, bevor eine dauerhafte Bestätigung empfangen wurde.
- HTTP %1$d: Anmeldung abgelehnt. Prüfe Bearer-Token und Weiterleitung des Authorization-Headers im Proxy.
- HTTP %1$d: Endpoint-Pfad nicht gefunden. Prüfe vollständige URL und Reverse-Proxy-Route.
- HTTP %1$d: Receiver oder Proxy hat bei der Verarbeitung das Zeitlimit überschritten.
- HTTP %1$d: Anfrage zu groß. Gleiche Body-Limits von Receiver und Reverse Proxy mit Protokoll 1.0 ab.
- HTTP %1$d: Der Receiver hat sowohl gzip- als auch unkomprimierte NDJSON-Uploads abgelehnt.
- HTTP %1$d: Der Receiver hat den Protokoll-Batch abgelehnt. Prüfe v1-Schema, Stream-Allowlist und Serverlogs.
- HTTP %1$d: Rate-Limit des Receivers erreicht. Automatische Wiederholung nutzt Backoff.
- HTTP %1$d: Fehler im Receiver oder Reverse Proxy. Prüfe Serverlogs und Health-Status.
- HTTP %1$d: Unerwarteter Clientfehler. Prüfe Endpoint- und Reverse-Proxy-Konfiguration.
- Die Capability-Antwort ist fehlerhaft oder entspricht nicht Protokoll 1.0.
- Die Bestätigung des Receivers ist fehlerhaft oder passt nicht zum gesendeten Batch. Der Cursor wurde nicht vorgerückt.
- Ein lokaler Datensatz überschreitet Protokollgrenzen oder lässt sich nicht exakt kodieren. Kein Cursor wurde vorgerückt.
- Lokale Datenbank oder Push-Fortschritt konnten nicht gelesen oder gespeichert werden.
- Warte auf WLAN.
- %1$s Eine automatische Wiederholung ist eingeplant.
- %1$s Automatische Wiederholungen sind pausiert. Behebe die Ursache und nutze dann „Jetzt exportieren“ oder synchronisiere den Strap erneut.
- Der gespeicherte Bearer-Token ist nicht verfügbar. Speichere ihn erneut.
- Der Export-Worker konnte nicht sicher gestartet werden.
- Der nächste begrenzte Exportschritt konnte nicht eingereiht werden.
- Server unterstützt %1$d von %2$d Datenarten
- Zuletzt geprüft: %1$s
- Dieser Server akzeptiert derzeit keine Gesundheitsdaten aus Protokoll 1.0.
- Akzeptierte Daten: %1$s
- Automatischen Export aktivieren
- Exportiert neue und geänderte Daten nach vollständiger Strap-Synchronisierung und holt Rückstände beim App-Start nach. Nur über WLAN.
- Jetzt exportieren
- Startet jetzt einen Abgleich. Der automatische Export muss aktiviert sein.
- Speichere vor dem Aktivieren einen gültigen Endpunkt und ein Token.
- Status
- Zugangsdaten erscheinen nie im Status oder in Worker-Metadaten.
- Nie
- Letzter vollständiger Abgleich: %1$s
- Letzter Fehler: %1$s
- Aktueller Status: %1$s
- Aktueller Export: %1$d Batches · %2$d Datensätze akzeptiert
- Aktuelle Datenart: %1$s
- Inaktiv
- Eingeplant
- Wird gesendet
- Weitere lokale Daten gefunden; wird fortgesetzt
- Wiederholung nach einem Fehler
- Aktuell
- Nach einem Fehler pausiert
- Experimenteller Einweg-Export an einen von dir kontrollierten Endpunkt. Standardmäßig aus.
From 62de1ef15e9c0bf2ad2bc9b85641606007778cf0 Mon Sep 17 00:00:00 2001
From: Bernd Helm
Date: Wed, 26 Aug 2026 20:10:50 +0200
Subject: [PATCH 14/45] Add raw capture session deletion controls
---
.../noop/testcentre/GroundTruthCollector.kt | 51 ++++++++++++
.../com/noop/ui/GroundTruthCollectorScreen.kt | 83 ++++++++++++++++++-
.../app/src/main/res/values-de/strings.xml | 8 ++
android/app/src/main/res/values/strings.xml | 8 ++
4 files changed, 146 insertions(+), 4 deletions(-)
diff --git a/android/app/src/main/java/com/noop/testcentre/GroundTruthCollector.kt b/android/app/src/main/java/com/noop/testcentre/GroundTruthCollector.kt
index 388f97da04..884b30d8d6 100644
--- a/android/app/src/main/java/com/noop/testcentre/GroundTruthCollector.kt
+++ b/android/app/src/main/java/com/noop/testcentre/GroundTruthCollector.kt
@@ -150,6 +150,42 @@ class GroundTruthCollector private constructor(private val context: Context) {
.sortedByDescending { it.startedAtMs }
}
+ /** Delete one completed capture and all app-private files and metadata belonging to it. */
+ @Synchronized
+ fun deleteSession(sessionId: String): Boolean {
+ val current = snapshot()
+ if (current.active && current.sessionId == sessionId) return false
+
+ val files = listOf(
+ eventFile(sessionId),
+ File(directory, "realtime-imu-$sessionId.bin"),
+ File(context.cacheDir, "logs/noop-ground-truth-$sessionId.zip"),
+ )
+ // Evaluate every deletion even if one fails, so a stale export ZIP never survives merely
+ // because an earlier file could not be removed.
+ val filesDeleted = files.map { file -> !file.exists() || file.delete() }.all { it }
+ if (eventFile(sessionId).exists()) return false
+
+ prefs.edit()
+ .remove(sessionDeviceKey(sessionId))
+ .remove(sessionStartKey(sessionId))
+ .remove(sessionEndKey(sessionId))
+ .remove(sessionCommentKey(sessionId))
+ .remove(sessionExportedKey(sessionId))
+ .apply {
+ if (current.sessionId == sessionId) clearCurrentSession()
+ }
+ .apply()
+ return filesDeleted
+ }
+
+ /** Delete every completed capture. Active captures are deliberately retained. */
+ @Synchronized
+ fun deleteAllSessions(): Int {
+ val completedIds = sessions().filterNot(SessionSummary::active).map(SessionSummary::id)
+ return completedIds.count(::deleteSession)
+ }
+
@Synchronized
fun record(kind: String, key: KeyInfo, noopSteps: Int?, nowMs: Long = System.currentTimeMillis()): Snapshot {
val before = snapshot()
@@ -485,6 +521,21 @@ class GroundTruthCollector private constructor(private val context: Context) {
if (value == null) remove("noopStepsAtStart") else putInt("noopStepsAtStart", value)
}
+ private fun android.content.SharedPreferences.Editor.clearCurrentSession() = apply {
+ remove("active")
+ remove("exported")
+ remove("sessionId")
+ remove("deviceId")
+ remove("startedAtMs")
+ remove("endedAtMs")
+ remove("steps")
+ remove("stairs")
+ remove("lastKind")
+ remove("lastKey")
+ remove("noopStepsAtStart")
+ remove("lastNoopSteps")
+ }
+
private fun sessionDeviceKey(id: String) = "session.$id.deviceId"
private fun sessionStartKey(id: String) = "session.$id.startedAtMs"
private fun sessionEndKey(id: String) = "session.$id.endedAtMs"
diff --git a/android/app/src/main/java/com/noop/ui/GroundTruthCollectorScreen.kt b/android/app/src/main/java/com/noop/ui/GroundTruthCollectorScreen.kt
index eec206d5d7..fa113c7a10 100644
--- a/android/app/src/main/java/com/noop/ui/GroundTruthCollectorScreen.kt
+++ b/android/app/src/main/java/com/noop/ui/GroundTruthCollectorScreen.kt
@@ -14,6 +14,8 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
@@ -60,6 +62,8 @@ fun GroundTruthCollectorScreen(vm: AppViewModel) {
var excludeMinutes by remember { mutableStateOf("5") }
var sessions by remember { mutableStateOf(collector.sessions()) }
var latestSensorTs by remember { mutableStateOf(null) }
+ var sessionPendingDelete by remember { mutableStateOf(null) }
+ var confirmDeleteAll by remember { mutableStateOf(false) }
LaunchedEffect(vm.activeStrapId) {
while (true) {
@@ -234,6 +238,13 @@ fun GroundTruthCollectorScreen(vm: AppViewModel) {
if (sessions.isEmpty()) {
Text(stringResource(R.string.ground_truth_no_sessions), style = NoopType.body, color = Palette.textSecondary)
} else {
+ NoopButton(
+ text = stringResource(R.string.ground_truth_delete_all),
+ kind = NoopButtonKind.Destructive,
+ fullWidth = true,
+ enabled = sessions.none { it.active },
+ onClick = { confirmDeleteAll = true },
+ )
sessions.forEach { session ->
SessionCard(
session = session,
@@ -259,10 +270,57 @@ fun GroundTruthCollectorScreen(vm: AppViewModel) {
}
}
},
+ onDelete = { sessionPendingDelete = session },
)
}
}
}
+
+ sessionPendingDelete?.let { session ->
+ AlertDialog(
+ onDismissRequest = { sessionPendingDelete = null },
+ title = { Text(stringResource(R.string.ground_truth_delete_session_title)) },
+ text = { Text(stringResource(R.string.ground_truth_delete_session_message, sessionTimeRange(session))) },
+ dismissButton = {
+ TextButton(onClick = { sessionPendingDelete = null }) {
+ Text(stringResource(R.string.ground_truth_cancel))
+ }
+ },
+ confirmButton = {
+ TextButton(onClick = {
+ collector.deleteSession(session.id)
+ sessions = collector.sessions()
+ state = collector.snapshot()
+ sessionPendingDelete = null
+ }) {
+ Text(stringResource(R.string.ground_truth_delete_confirm), color = Palette.statusCritical)
+ }
+ },
+ )
+ }
+
+ if (confirmDeleteAll) {
+ AlertDialog(
+ onDismissRequest = { confirmDeleteAll = false },
+ title = { Text(stringResource(R.string.ground_truth_delete_all_title)) },
+ text = { Text(stringResource(R.string.ground_truth_delete_all_message, sessions.size)) },
+ dismissButton = {
+ TextButton(onClick = { confirmDeleteAll = false }) {
+ Text(stringResource(R.string.ground_truth_cancel))
+ }
+ },
+ confirmButton = {
+ TextButton(onClick = {
+ collector.deleteAllSessions()
+ sessions = collector.sessions()
+ state = collector.snapshot()
+ confirmDeleteAll = false
+ }) {
+ Text(stringResource(R.string.ground_truth_delete_confirm), color = Palette.statusCritical)
+ }
+ },
+ )
+ }
}
@Composable
@@ -273,11 +331,9 @@ private fun SessionCard(
exporting: Boolean,
onComment: (String) -> Unit,
onExport: () -> Unit,
+ onDelete: () -> Unit,
) {
- val time = remember(session.startedAtMs) {
- java.text.DateFormat.getDateTimeInstance(java.text.DateFormat.SHORT, java.text.DateFormat.SHORT)
- .format(java.util.Date(session.startedAtMs))
- }
+ val time = remember(session.startedAtMs, session.endedAtMs) { sessionTimeRange(session) }
NoopCard {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
val sensorCovered = session.endedAtMs?.let { end -> latestSensorTs?.times(1000)?.let { it >= end } } == true
@@ -321,10 +377,29 @@ private fun SessionCard(
enabled = !session.active && !exporting,
onClick = onExport,
)
+ NoopButton(
+ text = stringResource(R.string.ground_truth_delete_session),
+ kind = NoopButtonKind.Destructive,
+ fullWidth = true,
+ enabled = !session.active && !exporting,
+ onClick = onDelete,
+ )
}
}
}
+private fun sessionTimeRange(session: GroundTruthCollector.SessionSummary): String {
+ val date = java.text.DateFormat.getDateInstance(java.text.DateFormat.SHORT)
+ val time = java.text.DateFormat.getTimeInstance(java.text.DateFormat.SHORT)
+ val from = java.util.Date(session.startedAtMs)
+ val to = session.endedAtMs?.let { java.util.Date(it) }
+ return if (to == null) {
+ "${date.format(from)} · ${time.format(from)}–…"
+ } else {
+ "${date.format(from)} · ${time.format(from)}–${time.format(to)}"
+ }
+}
+
@Composable
private fun CounterColumn(label: String, value: String, modifier: Modifier = Modifier) {
Column(modifier = modifier.padding(horizontal = 4.dp), horizontalAlignment = Alignment.CenterHorizontally) {
diff --git a/android/app/src/main/res/values-de/strings.xml b/android/app/src/main/res/values-de/strings.xml
index d15916427a..ac271cb7e5 100644
--- a/android/app/src/main/res/values-de/strings.xml
+++ b/android/app/src/main/res/values-de/strings.xml
@@ -2492,5 +2492,13 @@
Noch keine Sessions aufgezeichnet.
%1$d Schritte · %2$d Stufen · %3$d ausgeschlossene Zeiträume
Kommentar zur Session
+ Session löschen
+ Diese Session löschen?
+ Die Session %1$s und ihre aufgezeichneten Rohdaten werden dauerhaft gelöscht.
+ Alle Sessions löschen
+ Alle Sessions löschen?
+ Alle %1$d aufgezeichneten Sessions und ihre Rohdaten werden dauerhaft gelöscht.
+ Löschen
+ Abbrechen
Ältere Session: Ereignisse können exportiert werden, der zugehörige Strap für Sensordaten ist aber nicht mehr eindeutig.
diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml
index 6635a8bbfc..6e78c0e0b1 100644
--- a/android/app/src/main/res/values/strings.xml
+++ b/android/app/src/main/res/values/strings.xml
@@ -2504,5 +2504,13 @@
No sessions recorded yet.
%1$d steps · %2$d stairs · %3$d excluded periods
Session comment
+ Delete session
+ Delete this session?
+ The session %1$s and its captured raw data will be deleted permanently.
+ Delete all sessions
+ Delete all sessions?
+ All %1$d recorded sessions and their captured raw data will be deleted permanently.
+ Delete
+ Cancel
Legacy session: event export is available, but its strap cannot be identified for sensor export.
From 14d5ca8c451f3f334b949925cb7748934b32cab4 Mon Sep 17 00:00:00 2001
From: Bernd Helm
Date: Wed, 26 Aug 2026 20:30:18 +0200
Subject: [PATCH 15/45] Bring iOS raw capture sessions to Android parity
---
.../Sources/WhoopStore/RawOutbox.swift | 27 ++
.../Tests/WhoopStoreTests/PruneTests.swift | 27 ++
Strand/BLE/BLEManager.swift | 39 +++
Strand/Collect/Collector.swift | 18 ++
Strand/Collect/RawCaptureWindow.swift | 4 +-
Strand/Collect/RawDataSessionStore.swift | 198 ++++++++++++++
Strand/Screens/RawDataCollectorView.swift | 245 ++++++++++++++++++
Strand/Screens/TestCentreView.swift | 25 +-
StrandTests/RawDataSessionStoreTests.swift | 50 ++++
9 files changed, 614 insertions(+), 19 deletions(-)
create mode 100644 Strand/Collect/RawDataSessionStore.swift
create mode 100644 Strand/Screens/RawDataCollectorView.swift
create mode 100644 StrandTests/RawDataSessionStoreTests.swift
diff --git a/Packages/WhoopStore/Sources/WhoopStore/RawOutbox.swift b/Packages/WhoopStore/Sources/WhoopStore/RawOutbox.swift
index a08a53a9aa..4834619926 100644
--- a/Packages/WhoopStore/Sources/WhoopStore/RawOutbox.swift
+++ b/Packages/WhoopStore/Sources/WhoopStore/RawOutbox.swift
@@ -164,6 +164,33 @@ extension WhoopStore {
}
}
+ /// Raw batches overlapping a user-selected capture interval, oldest first.
+ public func rawBatches(deviceId: String, from: Int, to: Int) async throws -> [RawBatchMeta] {
+ guard from <= to else { return [] }
+ return try syncRead { db in
+ try Row.fetchAll(db, sql: """
+ SELECT batchId, deviceId, capturedAt, deviceClockRef, wallClockRef,
+ startTs, endTs, frameCount, byteSize
+ FROM rawBatch
+ WHERE deviceId = ? AND capturedAt >= ? AND capturedAt <= ?
+ ORDER BY capturedAt ASC, rowid ASC
+ """, arguments: [deviceId, from, to]).map(WhoopStore.metaFromRow)
+ }
+ }
+
+ /// Remove raw batches belonging to a deleted user capture interval.
+ @discardableResult
+ public func deleteRawBatches(deviceId: String, from: Int, to: Int) async throws -> Int {
+ guard from <= to else { return 0 }
+ return try syncWrite { db in
+ try db.execute(sql: """
+ DELETE FROM rawBatch
+ WHERE deviceId = ? AND capturedAt >= ? AND capturedAt <= ?
+ """, arguments: [deviceId, from, to])
+ return db.changesCount
+ }
+ }
+
/// Mark a batch synced (timestamp in unix seconds).
///
/// #981 — NO production caller, and by design: see the dormancy note on `pruneRaw`'s Policy 1. Kept
diff --git a/Packages/WhoopStore/Tests/WhoopStoreTests/PruneTests.swift b/Packages/WhoopStore/Tests/WhoopStoreTests/PruneTests.swift
index 1ba9dcf511..d054375f67 100644
--- a/Packages/WhoopStore/Tests/WhoopStoreTests/PruneTests.swift
+++ b/Packages/WhoopStore/Tests/WhoopStoreTests/PruneTests.swift
@@ -77,4 +77,31 @@ final class PruneTests: XCTestCase {
maxUnsyncedBytes: 1_000_000)
XCTAssertEqual(pruned, 0)
}
+
+ func testCaptureIntervalListsOnlyMatchingDeviceAndWallTime() async throws {
+ let store = try await WhoopStore.inMemory()
+ try await store.upsertDevice(id: "dev1", mac: nil, name: nil)
+ try await store.upsertDevice(id: "dev2", mac: nil, name: nil)
+ try await store.enqueueRawBatch(meta("before", capturedAt: 99, bytes: 10), frames: frames)
+ try await store.enqueueRawBatch(meta("inside", capturedAt: 100, bytes: 10), frames: frames)
+ let other = RawBatchMeta(batchId: "other", deviceId: "dev2", clockRef: .init(device: 0, wall: 0),
+ capturedAt: 100, startTs: 0, endTs: 0,
+ frameCount: frames.count, byteSize: 10)
+ try await store.enqueueRawBatch(other, frames: frames)
+
+ let rows = try await store.rawBatches(deviceId: "dev1", from: 100, to: 200)
+ XCTAssertEqual(rows.map(\.batchId), ["inside"])
+ }
+
+ func testDeletingCaptureIntervalDoesNotTouchOtherRaw() async throws {
+ let store = try await WhoopStore.inMemory()
+ try await store.upsertDevice(id: "dev1", mac: nil, name: nil)
+ try await store.enqueueRawBatch(meta("before", capturedAt: 99, bytes: 10), frames: frames)
+ try await store.enqueueRawBatch(meta("first", capturedAt: 100, bytes: 10), frames: frames)
+ try await store.enqueueRawBatch(meta("last", capturedAt: 200, bytes: 10), frames: frames)
+ try await store.enqueueRawBatch(meta("after", capturedAt: 201, bytes: 10), frames: frames)
+
+ XCTAssertEqual(try await store.deleteRawBatches(deviceId: "dev1", from: 100, to: 200), 2)
+ XCTAssertEqual(try await store.allBatchIdsForTest(), ["before", "after"])
+ }
}
diff --git a/Strand/BLE/BLEManager.swift b/Strand/BLE/BLEManager.swift
index fb841ddb7c..f180775095 100644
--- a/Strand/BLE/BLEManager.swift
+++ b/Strand/BLE/BLEManager.swift
@@ -1784,6 +1784,42 @@ public final class BLEManager: NSObject, ObservableObject {
}
}
+ /// Start a manually stopped 5/MG raw-data session. Returns false when another capture is active.
+ @discardableResult
+ public func startGroundTruthRawCapture() -> Bool {
+ guard !rawCaptureInFlight else { return false }
+ rawCaptureInFlight = true
+ collector?.beginRawCapture(seconds: RawCaptureWindow.maxSeconds)
+ send(.startRawData, payload: [0x01], writeType: .withResponse)
+ send(.toggleIMUMode,
+ payload: selectedModel.deviceFamily == .whoop5 ? [0x01, 0x01] : [0x01],
+ writeType: .withResponse)
+ log("Raw-data session: started")
+ return true
+ }
+
+ /// Stop and flush the current manually controlled raw-data session.
+ public func stopGroundTruthRawCapture() async {
+ if rawCaptureInFlight && !UserDefaults.standard.bool(forKey: "enableRawCapture") {
+ send(.stopRawData, payload: [0x01], writeType: .withResponse)
+ if selectedModel.deviceFamily == .whoop5 {
+ send(.toggleIMUMode, payload: [0x01, 0x00], writeType: .withResponse)
+ }
+ }
+ await collector?.endRawCapture()
+ rawCaptureInFlight = false
+ log("Raw-data session: stopped + flushed")
+ }
+
+ public func groundTruthRawBatches(from: Int, to: Int) async -> [(RawBatchMeta, [[UInt8]])] {
+ await collector?.rawBatches(from: from, to: to) ?? []
+ }
+
+ @discardableResult
+ public func deleteGroundTruthRawBatches(from: Int, to: Int) async -> Int {
+ await collector?.deleteRawBatches(from: from, to: to) ?? 0
+ }
+
/// Send a command to the WHOOP strap.
/// - Parameters:
/// - command: The command to send.
@@ -5023,6 +5059,9 @@ extension BLEManager: @preconcurrency CBCentralManagerDelegate {
didBond = false
clientHelloWriteAt = nil // #1635: no hello survives the link that carried it
whoop5RealtimeArmed = false
+ // A user capture remains represented by RawDataSessionStore, but its transport must be re-armed
+ // on the next connection. Keeping this true would make that reconnect attempt a silent no-op.
+ rawCaptureInFlight = false
// The strap forgets the realtime-HR toggle across a disconnect; the post-bond branch re-arms it
// from `wantsRealtime`. Clear only the "what we last sent" flag — `screenWantsRealtime` /
// `keepRealtimeForData` (and thus `wantsRealtime`) are intent and must survive a reconnect so the
diff --git a/Strand/Collect/Collector.swift b/Strand/Collect/Collector.swift
index 87279bfa0a..32c9eef273 100644
--- a/Strand/Collect/Collector.swift
+++ b/Strand/Collect/Collector.swift
@@ -97,6 +97,24 @@ final class Collector {
return try? await s.storageStats()
}
+ func rawBatches(from: Int, to: Int) async -> [(RawBatchMeta, [[UInt8]])] {
+ guard let store = concreteStore,
+ let metas = try? await store.rawBatches(deviceId: deviceId, from: from, to: to) else { return [] }
+ var result: [(RawBatchMeta, [[UInt8]])] = []
+ for meta in metas {
+ if let frames = try? await store.rawFrames(batchId: meta.batchId) {
+ result.append((meta, frames))
+ }
+ }
+ return result
+ }
+
+ @discardableResult
+ func deleteRawBatches(from: Int, to: Int) async -> Int {
+ guard let store = concreteStore else { return 0 }
+ return (try? await store.deleteRawBatches(deviceId: deviceId, from: from, to: to)) ?? 0
+ }
+
/// Max persisted HR sample ts (the biometric "data frontier" for the stuck-strap watchdog).
/// nil if there's no concrete store or nothing persisted yet. Mirrors storageStats().
func latestHRSampleTs() async -> Int? {
diff --git a/Strand/Collect/RawCaptureWindow.swift b/Strand/Collect/RawCaptureWindow.swift
index 336273ff4a..833868f325 100644
--- a/Strand/Collect/RawCaptureWindow.swift
+++ b/Strand/Collect/RawCaptureWindow.swift
@@ -5,7 +5,9 @@ import Foundation
/// auto-expires the window so a missed stop callback can't leak raw forever.
struct RawCaptureWindow {
static let minSeconds: TimeInterval = 1
- static let maxSeconds: TimeInterval = 300
+ // A manually stopped research session may legitimately last for hours. It is still bounded so an
+ // interrupted UI/process cannot accidentally turn the opt-in capture into permanent 24/7 logging.
+ static let maxSeconds: TimeInterval = 24 * 60 * 60
static func clamp(_ s: TimeInterval) -> TimeInterval { min(max(s, minSeconds), maxSeconds) }
private var deadline: TimeInterval? // monotonic deadline; nil = inactive
diff --git a/Strand/Collect/RawDataSessionStore.swift b/Strand/Collect/RawDataSessionStore.swift
new file mode 100644
index 0000000000..77189fd46a
--- /dev/null
+++ b/Strand/Collect/RawDataSessionStore.swift
@@ -0,0 +1,198 @@
+import Combine
+import Foundation
+import WhoopStore
+
+/// Persistent, user-controlled 5/MG capture sessions. This is the iOS/macOS twin of Android's
+/// GroundTruthCollector: raw capture is useful on its own, while step/stair labels are optional.
+@MainActor
+final class RawDataSessionStore: ObservableObject {
+ enum LabelKind: String, Codable {
+ case start, stop, step, stair
+ case undoStep = "undo_step"
+ case undoStair = "undo_stair"
+ case excludeWindow = "exclude_window"
+ }
+
+ struct Event: Codable, Identifiable, Equatable {
+ var id = UUID()
+ let atMs: Int64
+ let kind: LabelKind
+ let stepsTotal: Int
+ let stairsTotal: Int
+ var fromMs: Int64?
+ var toMs: Int64?
+ }
+
+ struct Session: Codable, Identifiable, Equatable {
+ let id: String
+ let deviceId: String
+ let startedAtMs: Int64
+ var endedAtMs: Int64?
+ var steps: Int
+ var stairs: Int
+ var excludedWindows: Int
+ var comment: String
+ var exported: Bool
+ var events: [Event]
+
+ var active: Bool { endedAtMs == nil }
+
+ static func == (lhs: Session, rhs: Session) -> Bool {
+ lhs.id == rhs.id && lhs.endedAtMs == rhs.endedAtMs && lhs.steps == rhs.steps
+ && lhs.stairs == rhs.stairs && lhs.excludedWindows == rhs.excludedWindows
+ && lhs.comment == rhs.comment && lhs.exported == rhs.exported
+ }
+ }
+
+ @Published private(set) var sessions: [Session] = []
+ var active: Session? { sessions.first(where: \.active) }
+
+ private let directory: URL
+ private let encoder: JSONEncoder
+ private let decoder = JSONDecoder()
+
+ init(directory override: URL? = nil, fileManager: FileManager = .default) {
+ let base = (try? fileManager.url(for: .applicationSupportDirectory, in: .userDomainMask,
+ appropriateFor: nil, create: true)) ?? fileManager.temporaryDirectory
+ directory = override ?? base.appendingPathComponent("OpenWhoop/RawDataSessions", isDirectory: true)
+ try? fileManager.createDirectory(at: directory, withIntermediateDirectories: true)
+ encoder = JSONEncoder()
+ encoder.outputFormatting = [.sortedKeys]
+ reload()
+ }
+
+ func reload() {
+ let urls = (try? FileManager.default.contentsOfDirectory(at: directory,
+ includingPropertiesForKeys: nil)) ?? []
+ sessions = urls.filter { $0.pathExtension == "json" }
+ .compactMap { try? Data(contentsOf: $0) }
+ .compactMap { try? decoder.decode(Session.self, from: $0) }
+ .sorted { $0.startedAtMs > $1.startedAtMs }
+ }
+
+ @discardableResult
+ func start(deviceId: String, now: Date = Date()) -> Session? {
+ guard active == nil else { return nil }
+ let millis = Int64(now.timeIntervalSince1970 * 1_000)
+ let started = Event(atMs: millis, kind: .start, stepsTotal: 0, stairsTotal: 0)
+ let session = Session(id: String(millis), deviceId: deviceId, startedAtMs: millis,
+ endedAtMs: nil, steps: 0, stairs: 0, excludedWindows: 0,
+ comment: "", exported: false, events: [started])
+ sessions.insert(session, at: 0)
+ persist(session)
+ return session
+ }
+
+ func stop(now: Date = Date()) {
+ mutateActive { session in
+ let millis = Int64(now.timeIntervalSince1970 * 1_000)
+ session.endedAtMs = millis
+ session.events.append(Event(atMs: millis, kind: .stop,
+ stepsTotal: session.steps, stairsTotal: session.stairs))
+ }
+ }
+
+ func record(_ kind: LabelKind, now: Date = Date()) {
+ guard kind == .step || kind == .stair else { return }
+ mutateActive { session in
+ session.steps += 1
+ if kind == .stair { session.stairs += 1 }
+ session.events.append(Event(atMs: Int64(now.timeIntervalSince1970 * 1_000), kind: kind,
+ stepsTotal: session.steps, stairsTotal: session.stairs))
+ }
+ }
+
+ func undo(now: Date = Date()) {
+ mutateActive { session in
+ guard let prior = session.events.last,
+ prior.kind == .step || prior.kind == .stair else { return }
+ let undo: LabelKind = prior.kind == .stair ? .undoStair : .undoStep
+ session.steps = max(0, session.steps - 1)
+ if prior.kind == .stair { session.stairs = max(0, session.stairs - 1) }
+ session.events.append(Event(atMs: Int64(now.timeIntervalSince1970 * 1_000), kind: undo,
+ stepsTotal: session.steps, stairsTotal: session.stairs))
+ }
+ }
+
+ func excludeLast(minutes: Int, now: Date = Date()) {
+ guard (1...240).contains(minutes) else { return }
+ mutateActive { session in
+ let to = Int64(now.timeIntervalSince1970 * 1_000)
+ let from = max(session.startedAtMs, to - Int64(minutes) * 60_000)
+ session.excludedWindows += 1
+ session.events.append(Event(atMs: to, kind: .excludeWindow, stepsTotal: session.steps,
+ stairsTotal: session.stairs, fromMs: from, toMs: to))
+ }
+ }
+
+ func setComment(_ comment: String, sessionId: String) {
+ mutate(sessionId) { $0.comment = String(comment.prefix(4_000)) }
+ }
+
+ func markExported(_ sessionId: String) { mutate(sessionId) { $0.exported = true } }
+
+ func removeMetadata(_ sessionId: String) {
+ guard sessions.first(where: { $0.id == sessionId })?.active != true else { return }
+ try? FileManager.default.removeItem(at: file(sessionId))
+ sessions.removeAll { $0.id == sessionId }
+ }
+
+ private func mutateActive(_ body: (inout Session) -> Void) {
+ guard let index = sessions.firstIndex(where: \.active) else { return }
+ body(&sessions[index])
+ persist(sessions[index])
+ }
+
+ private func mutate(_ id: String, _ body: (inout Session) -> Void) {
+ guard let index = sessions.firstIndex(where: { $0.id == id }) else { return }
+ body(&sessions[index])
+ persist(sessions[index])
+ }
+
+ private func persist(_ session: Session) {
+ guard let data = try? encoder.encode(session) else { return }
+ try? data.write(to: file(session.id), options: .atomic)
+ }
+
+ private func file(_ id: String) -> URL { directory.appendingPathComponent("session-\(id).json") }
+
+ func exportEntries(for session: Session,
+ raw: [(RawBatchMeta, [[UInt8]])]) -> [FileExport.BundleEntry] {
+ let meta: [String: Any] = [
+ "schema_version": 3, "capture_kind": "whoop_5mg_raw_data",
+ "session_id": session.id, "device_id": session.deviceId,
+ "started_at_ms": session.startedAtMs, "ended_at_ms": session.endedAtMs ?? session.startedAtMs,
+ "manual_steps": session.steps, "manual_stairs": session.stairs,
+ "comment": session.comment, "excluded_windows": session.excludedWindows,
+ "sensor_export_available": !raw.isEmpty,
+ "device_family": "iOS",
+ "app_version": Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "?",
+ "manual_labels": "optional; on-screen step and stair controls",
+ ]
+ let metaData = (try? JSONSerialization.data(withJSONObject: meta, options: [.prettyPrinted, .sortedKeys])) ?? Data()
+ let eventData = session.events.compactMap { event -> Data? in
+ var row: [String: Any] = ["at_ms": event.atMs, "kind": event.kind.rawValue,
+ "steps_total": event.stepsTotal, "stairs_total": event.stairsTotal]
+ if let from = event.fromMs { row["from_ms"] = from }
+ if let to = event.toMs { row["to_ms"] = to }
+ return try? JSONSerialization.data(withJSONObject: row, options: [.sortedKeys])
+ }.reduce(into: Data()) { $0.append($1); $0.append(0x0A) }
+ var rawData = Data()
+ for (meta, frames) in raw {
+ for (index, frame) in frames.enumerated() {
+ let row: [String: Any] = ["batch_id": meta.batchId, "captured_at": meta.capturedAt,
+ "start_ts": meta.startTs, "end_ts": meta.endTs,
+ "frame_index": index,
+ "hex": frame.map { String(format: "%02x", $0) }.joined()]
+ if let data = try? JSONSerialization.data(withJSONObject: row, options: [.sortedKeys]) {
+ rawData.append(data); rawData.append(0x0A)
+ }
+ }
+ }
+ return [
+ .init(name: "meta.json", data: metaData),
+ .init(name: "events.jsonl", data: eventData),
+ .init(name: "raw-frames.jsonl", data: rawData),
+ ]
+ }
+}
diff --git a/Strand/Screens/RawDataCollectorView.swift b/Strand/Screens/RawDataCollectorView.swift
new file mode 100644
index 0000000000..7613b590aa
--- /dev/null
+++ b/Strand/Screens/RawDataCollectorView.swift
@@ -0,0 +1,245 @@
+import SwiftUI
+import StrandDesign
+
+/// iOS/macOS parity twin of Android's 5/MG Raw Data Collector screen.
+struct RawDataCollectorView: View {
+ @EnvironmentObject private var model: AppModel
+ @EnvironmentObject private var live: LiveState
+ @StateObject private var store = RawDataSessionStore()
+
+ @State private var excludeMinutes = "5"
+ @State private var exportingId: String?
+ @State private var deleteCandidate: RawDataSessionStore.Session?
+ @State private var confirmDeleteAll = false
+ @State private var exportError: String?
+ @State private var rawBatchCounts: [String: Int] = [:]
+
+ var body: some View {
+ ScreenScaffold(
+ title: "5/MG Raw Data Collector",
+ subtitle: "Record a bounded 100 Hz motion session and export its complete timeline."
+ ) {
+ VStack(alignment: .leading, spacing: NoopMetrics.sectionSpacing) {
+ countersCard
+ coverageCard
+ labelsCard
+ controls
+ sessionsSection
+ }
+ }
+ .task {
+ // Restore a session after navigation/process lifecycle changes. The BLE layer rejects a
+ // duplicate arm, so this is safe when capture never stopped.
+ if store.active != nil, live.bonded { model.ble.startGroundTruthRawCapture() }
+ await refreshRawCounts()
+ }
+ .onChangeCompat(of: live.bonded) { bonded in
+ if bonded, store.active != nil { model.ble.startGroundTruthRawCapture() }
+ }
+ .confirmationDialog("Delete this session?", isPresented: Binding(
+ get: { deleteCandidate != nil }, set: { if !$0 { deleteCandidate = nil } }
+ ), titleVisibility: .visible) {
+ Button("Delete", role: .destructive) {
+ guard let session = deleteCandidate else { return }
+ deleteCandidate = nil
+ Task { await delete(session) }
+ }
+ Button("Cancel", role: .cancel) { deleteCandidate = nil }
+ } message: {
+ if let session = deleteCandidate {
+ Text("The session \(Self.range(session)) and its captured raw data will be deleted permanently.")
+ }
+ }
+ .confirmationDialog("Delete all sessions?", isPresented: $confirmDeleteAll,
+ titleVisibility: .visible) {
+ Button("Delete all", role: .destructive) { Task { await deleteAll() } }
+ Button("Cancel", role: .cancel) { }
+ } message: {
+ Text("All \(store.sessions.count) recorded sessions and their captured raw data will be deleted permanently.")
+ }
+ .alert("Couldn't export session", isPresented: Binding(
+ get: { exportError != nil }, set: { if !$0 { exportError = nil } }
+ )) {
+ Button("OK", role: .cancel) { exportError = nil }
+ } message: { Text(exportError ?? "Unknown error") }
+ }
+
+ private var countersCard: some View {
+ StrandCard {
+ HStack {
+ counter("NOOP steps", model.repo.days.last?.steps.map(String.init) ?? "–")
+ counter("Manual steps", String(store.active?.steps ?? 0))
+ counter("Stairs", String(store.active?.stairs ?? 0))
+ }
+ }
+ }
+
+ private var coverageCard: some View {
+ StrandCard {
+ VStack(alignment: .leading, spacing: NoopMetrics.space2) {
+ Text("Capture coverage").font(StrandFont.headline).foregroundStyle(StrandPalette.textPrimary)
+ Text(live.connected ? "Band: connected\(live.bonded ? " + paired" : "; pairing")"
+ : "Band: disconnected")
+ .foregroundStyle(live.connected ? StrandPalette.statusPositive : StrandPalette.statusCritical)
+ Text(live.backfilling
+ ? "History sync: running (\(live.syncChunksThisSession) chunks)"
+ : "History sync: idle")
+ .foregroundStyle(StrandPalette.textSecondary)
+ if let active = store.active {
+ Text("Realtime IMU: session active since \(Self.time(active.startedAtMs))")
+ .foregroundStyle(StrandPalette.accent)
+ }
+ }
+ .font(StrandFont.subhead)
+ }
+ }
+
+ private var labelsCard: some View {
+ StrandCard {
+ VStack(alignment: .leading, spacing: NoopMetrics.space3) {
+ Text("Optional manual labels").font(StrandFont.headline)
+ .foregroundStyle(StrandPalette.textPrimary)
+ Text("For step-algorithm research, label one step or one stair plus one step. Raw capture works without labels.")
+ .font(StrandFont.caption).foregroundStyle(StrandPalette.textTertiary)
+ HStack {
+ NoopButton("Step", systemImage: "figure.walk", kind: .secondary, fullWidth: true) {
+ store.record(.step)
+ }.disabled(store.active == nil)
+ NoopButton("Stair", systemImage: "stairs", kind: .secondary, fullWidth: true) {
+ store.record(.stair)
+ }.disabled(store.active == nil)
+ }
+ }
+ }
+ }
+
+ @ViewBuilder private var controls: some View {
+ if store.active != nil {
+ HStack {
+ NoopButton("Undo last click", systemImage: "arrow.uturn.backward", kind: .secondary,
+ fullWidth: true) { store.undo() }
+ NoopButton("Stop session", systemImage: "stop.fill", kind: .destructive,
+ fullWidth: true) { Task { await stop() } }
+ }
+ HStack {
+ TextField("Minutes", text: $excludeMinutes)
+ .textFieldStyle(.roundedBorder)
+ NoopButton("Ignore recent period", kind: .secondary, fullWidth: true) {
+ if let minutes = Int(excludeMinutes) { store.excludeLast(minutes: minutes) }
+ }
+ .disabled(!(1...240).contains(Int(excludeMinutes) ?? 0))
+ }
+ } else {
+ NoopButton("Start raw-data session", systemImage: "record.circle", kind: .primary,
+ fullWidth: true) { start() }
+ .disabled(!live.bonded)
+ }
+ }
+
+ private var sessionsSection: some View {
+ VStack(alignment: .leading, spacing: NoopMetrics.space3) {
+ Text("Recorded sessions").font(StrandFont.title2).foregroundStyle(StrandPalette.textPrimary)
+ if store.sessions.isEmpty {
+ Text("No sessions recorded yet.").font(StrandFont.subhead)
+ .foregroundStyle(StrandPalette.textSecondary)
+ } else {
+ NoopButton("Delete all sessions", systemImage: "trash", kind: .destructive,
+ fullWidth: true) { confirmDeleteAll = true }
+ .disabled(store.active != nil)
+ ForEach(store.sessions) { session in sessionCard(session) }
+ }
+ }
+ }
+
+ private func sessionCard(_ session: RawDataSessionStore.Session) -> some View {
+ StrandCard {
+ VStack(alignment: .leading, spacing: NoopMetrics.space3) {
+ Text(Self.range(session)).font(StrandFont.headline).foregroundStyle(StrandPalette.textPrimary)
+ Text("\(session.steps) steps · \(session.stairs) stairs · \(session.excludedWindows) excluded periods")
+ .font(StrandFont.caption).foregroundStyle(StrandPalette.textSecondary)
+ Text(session.active ? "Export status: recording"
+ : "Export status: \(rawBatchCounts[session.id, default: 0]) raw batches")
+ .font(StrandFont.caption)
+ .foregroundStyle(session.active ? StrandPalette.statusWarning : StrandPalette.statusPositive)
+ TextField("Session comment", text: Binding(
+ get: { store.sessions.first(where: { $0.id == session.id })?.comment ?? session.comment },
+ set: { store.setComment($0, sessionId: session.id) }
+ ), axis: .vertical)
+ .textFieldStyle(.roundedBorder).lineLimit(2...4)
+ NoopButton(exportingId == session.id ? "Building export…" : "Export session",
+ systemImage: "square.and.arrow.up", kind: .secondary, fullWidth: true) {
+ Task { await export(session) }
+ }
+ .disabled(session.active || exportingId != nil)
+ NoopButton("Delete session", systemImage: "trash", kind: .destructive,
+ fullWidth: true) { deleteCandidate = session }
+ .disabled(session.active || exportingId != nil)
+ }
+ }
+ }
+
+ private func counter(_ label: LocalizedStringKey, _ value: String) -> some View {
+ VStack(spacing: NoopMetrics.space1) {
+ Text(value).font(StrandFont.number(28)).foregroundStyle(StrandPalette.textPrimary)
+ Text(label).font(StrandFont.caption).foregroundStyle(StrandPalette.textSecondary)
+ .multilineTextAlignment(.center)
+ }.frame(maxWidth: .infinity)
+ }
+
+ private func start() {
+ guard model.ble.startGroundTruthRawCapture() else { return }
+ if store.start(deviceId: model.ble.deviceId) == nil {
+ Task { await model.ble.stopGroundTruthRawCapture() }
+ }
+ }
+
+ private func stop() async {
+ await model.ble.stopGroundTruthRawCapture()
+ store.stop()
+ await refreshRawCounts()
+ }
+
+ private func export(_ session: RawDataSessionStore.Session) async {
+ guard let end = session.endedAtMs else { return }
+ exportingId = session.id
+ let raw = await model.ble.groundTruthRawBatches(from: Int(session.startedAtMs / 1_000),
+ to: Int(end / 1_000))
+ let entries = store.exportEntries(for: session, raw: raw)
+ let result = await FileExport.exportBundle(entries: entries,
+ suggestedName: "noop-ground-truth-\(session.id).zip")
+ if result == nil { exportError = "The export file could not be created or shared." }
+ else { store.markExported(session.id) }
+ exportingId = nil
+ }
+
+ private func delete(_ session: RawDataSessionStore.Session) async {
+ guard let end = session.endedAtMs else { return }
+ _ = await model.ble.deleteGroundTruthRawBatches(from: Int(session.startedAtMs / 1_000),
+ to: Int(end / 1_000))
+ store.removeMetadata(session.id)
+ rawBatchCounts[session.id] = nil
+ }
+
+ private func deleteAll() async {
+ for session in store.sessions where !session.active { await delete(session) }
+ }
+
+ private func refreshRawCounts() async {
+ for session in store.sessions {
+ guard let end = session.endedAtMs else { continue }
+ rawBatchCounts[session.id] = await model.ble.groundTruthRawBatches(
+ from: Int(session.startedAtMs / 1_000), to: Int(end / 1_000)).count
+ }
+ }
+
+ private static func time(_ ms: Int64) -> String {
+ Date(timeIntervalSince1970: Double(ms) / 1_000).formatted(date: .omitted, time: .shortened)
+ }
+
+ private static func range(_ session: RawDataSessionStore.Session) -> String {
+ let start = Date(timeIntervalSince1970: Double(session.startedAtMs) / 1_000)
+ let date = start.formatted(date: .numeric, time: .omitted)
+ let end = session.endedAtMs.map(time) ?? "…"
+ return "\(date) · \(time(session.startedAtMs))–\(end)"
+ }
+}
diff --git a/Strand/Screens/TestCentreView.swift b/Strand/Screens/TestCentreView.swift
index 8a7ead9892..66b6e925cc 100644
--- a/Strand/Screens/TestCentreView.swift
+++ b/Strand/Screens/TestCentreView.swift
@@ -36,7 +36,6 @@ struct TestCentreView: View {
// Retention (#650): how many scheduled-export generations to keep, and the manual clear confirm.
@State private var debugExportKeep = ScheduledDebugExport.keepCount
@State private var showClearExportsConfirm = false
- @State private var rawMotionCaptureActive = false
// WHOOP 5/MG developer controls. These use the same persisted keys as the former Settings card;
// moving the UI does not reset an opt-in or lose the ability to undo a persistent strap write.
@@ -150,26 +149,16 @@ struct TestCentreView: View {
Text("5/MG RAW DATA COLLECTOR")
.font(StrandFont.overline).tracking(StrandFont.overlineTracking)
.foregroundStyle(StrandPalette.textSecondary)
- Text("Records a bounded 60-second, 100 Hz motion window. The verified start/stop sequence is used only while this user-started session is active; normal sync is unchanged.")
+ Text("Start, label, review, export, and delete bounded 100 Hz motion sessions. Normal sync is unchanged.")
.font(StrandFont.caption).foregroundStyle(StrandPalette.textTertiary)
.fixedSize(horizontal: false, vertical: true)
- NoopButton(
- rawMotionCaptureActive ? "Capturing 100 Hz motion…" : "Capture 60 seconds",
- systemImage: rawMotionCaptureActive ? "waveform.path.ecg" : "record.circle",
- kind: .primary
- ) {
- guard !rawMotionCaptureActive else { return }
- rawMotionCaptureActive = true
- model.ble.captureRawAccel(seconds: 60)
- Task { @MainActor in
- try? await Task.sleep(nanoseconds: 60_000_000_000)
- rawMotionCaptureActive = false
- }
+ NavigationLink {
+ RawDataCollectorView()
+ } label: {
+ Label("Open raw-data collector", systemImage: "waveform.path.ecg")
}
- .disabled(rawMotionCaptureActive || !live.connected)
- Text(live.connected
- ? "The bounded raw window is persisted locally and can be included in Test Centre diagnostics."
- : "Connect your WHOOP 5/MG to start a capture.")
+ .buttonStyle(NoopButtonStyle(.primary, fullWidth: true))
+ Text(live.connected ? "WHOOP 5/MG connected." : "Connect your WHOOP 5/MG to start a capture.")
.font(StrandFont.caption)
.foregroundStyle(live.connected ? StrandPalette.textSecondary : StrandPalette.statusWarning)
}
diff --git a/StrandTests/RawDataSessionStoreTests.swift b/StrandTests/RawDataSessionStoreTests.swift
new file mode 100644
index 0000000000..6bb96718cc
--- /dev/null
+++ b/StrandTests/RawDataSessionStoreTests.swift
@@ -0,0 +1,50 @@
+import XCTest
+@testable import Strand
+
+@MainActor
+final class RawDataSessionStoreTests: XCTestCase {
+ private func temporaryDirectory() throws -> URL {
+ let url = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
+ try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
+ addTeardownBlock { try? FileManager.default.removeItem(at: url) }
+ return url
+ }
+
+ func testLifecycleLabelsUndoExclusionAndReload() throws {
+ let directory = try temporaryDirectory()
+ let store = RawDataSessionStore(directory: directory)
+ XCTAssertNotNil(store.start(deviceId: "strap", now: Date(timeIntervalSince1970: 100)))
+ store.record(.step, now: Date(timeIntervalSince1970: 101))
+ store.record(.stair, now: Date(timeIntervalSince1970: 102))
+ store.undo(now: Date(timeIntervalSince1970: 103))
+ store.undo(now: Date(timeIntervalSince1970: 104)) // the same click cannot be undone twice
+ store.excludeLast(minutes: 5, now: Date(timeIntervalSince1970: 105))
+ store.stop(now: Date(timeIntervalSince1970: 106))
+
+ let session = try XCTUnwrap(store.sessions.first)
+ XCTAssertEqual(session.steps, 1)
+ XCTAssertEqual(session.stairs, 0)
+ XCTAssertEqual(session.excludedWindows, 1)
+ XCTAssertEqual(session.endedAtMs, 106_000)
+
+ let reloaded = RawDataSessionStore(directory: directory)
+ XCTAssertEqual(reloaded.sessions.first, session)
+ }
+
+ func testActiveSessionCannotBeDeleted() throws {
+ let store = RawDataSessionStore(directory: try temporaryDirectory())
+ let active = try XCTUnwrap(store.start(deviceId: "strap"))
+ store.removeMetadata(active.id)
+ XCTAssertEqual(store.sessions.map(\.id), [active.id])
+ }
+
+ func testCompletedSessionCanBeDeleted() throws {
+ let directory = try temporaryDirectory()
+ let store = RawDataSessionStore(directory: directory)
+ let session = try XCTUnwrap(store.start(deviceId: "strap"))
+ store.stop()
+ store.removeMetadata(session.id)
+ XCTAssertTrue(store.sessions.isEmpty)
+ XCTAssertTrue(RawDataSessionStore(directory: directory).sessions.isEmpty)
+ }
+}
From 79542543326eb965e183f4a09ed5821df85ece2a Mon Sep 17 00:00:00 2001
From: Bernd Helm
Date: Wed, 26 Aug 2026 21:17:07 +0200
Subject: [PATCH 16/45] Add hybrid rolling and durable IMU storage
---
.../Sources/WhoopStore/Database.swift | 20 ++++
.../Sources/WhoopStore/ImuChunkCatalog.swift | 62 +++++++++++++
.../Sources/WhoopStore/StreamStore.swift | 18 +++-
.../Resources/schema_oracle.json | 28 +++++-
Strand/BLE/BLEManager.swift | 17 ++++
Strand/Collect/Collector.swift | 37 ++++++++
Strand/Collect/ImuChunkArchiveStore.swift | 75 +++++++++++++++
Strand/Collect/RawDataSessionStore.swift | 48 +++++++++-
Strand/Screens/RawDataCollectorView.swift | 71 ++++++++++++--
StrandTests/RawDataCollectorParityTests.swift | 49 ++++++++++
.../Resources/raw_data_collector_parity.json | 73 +++++++++++++++
.../src/main/java/com/noop/data/Entities.kt | 22 +++++
.../src/main/java/com/noop/data/WhoopDao.kt | 12 +++
.../main/java/com/noop/data/WhoopDatabase.kt | 22 ++++-
.../java/com/noop/data/WhoopRepository.kt | 9 +-
.../noop/testcentre/GroundTruthCollector.kt | 69 +++++++++++++-
.../java/com/noop/testcentre/ImuChunkStore.kt | 93 +++++++++++++++++++
.../com/noop/ui/GroundTruthCollectorScreen.kt | 72 ++++++++++++++
.../app/src/main/res/values-de/strings.xml | 3 +
android/app/src/main/res/values/strings.xml | 3 +
.../testcentre/RawDataCollectorParityTest.kt | 49 ++++++++++
.../resources/raw_data_collector_parity.json | 73 +++++++++++++++
.../app/src/test/resources/schema_oracle.json | 28 +++++-
23 files changed, 933 insertions(+), 20 deletions(-)
create mode 100644 Packages/WhoopStore/Sources/WhoopStore/ImuChunkCatalog.swift
create mode 100644 Strand/Collect/ImuChunkArchiveStore.swift
create mode 100644 StrandTests/RawDataCollectorParityTests.swift
create mode 100644 StrandTests/Resources/raw_data_collector_parity.json
create mode 100644 android/app/src/main/java/com/noop/testcentre/ImuChunkStore.kt
create mode 100644 android/app/src/test/java/com/noop/testcentre/RawDataCollectorParityTest.kt
create mode 100644 android/app/src/test/resources/raw_data_collector_parity.json
diff --git a/Packages/WhoopStore/Sources/WhoopStore/Database.swift b/Packages/WhoopStore/Sources/WhoopStore/Database.swift
index 1ac4d04088..f2cf80f986 100644
--- a/Packages/WhoopStore/Sources/WhoopStore/Database.swift
+++ b/Packages/WhoopStore/Sources/WhoopStore/Database.swift
@@ -875,6 +875,26 @@ extension WhoopStore {
t.add(column: "burstIndex", .integer)
}
}
+ // v40: index immutable 100 Hz archive chunks whose payload lives outside the operational DB.
+ migrator.registerMigration("v40-imu-chunk-catalog") { db in
+ try db.create(table: "imuChunk") { t in
+ t.column("id", .text).primaryKey()
+ t.column("deviceId", .text).notNull()
+ t.column("startTs", .integer).notNull()
+ t.column("endTs", .integer).notNull()
+ t.column("sampleCount", .integer).notNull()
+ t.column("sampleRate", .integer).notNull()
+ t.column("formatVersion", .integer).notNull()
+ t.column("codec", .text).notNull()
+ t.column("relativePath", .text).notNull()
+ t.column("byteSize", .integer).notNull()
+ t.column("sha256", .text).notNull()
+ t.column("createdAt", .integer).notNull()
+ t.column("pinnedUntil", .integer)
+ }
+ try db.create(index: "index_imuChunk_deviceId_startTs_endTs", on: "imuChunk",
+ columns: ["deviceId", "startTs", "endTs"])
+ }
return migrator
}
}
diff --git a/Packages/WhoopStore/Sources/WhoopStore/ImuChunkCatalog.swift b/Packages/WhoopStore/Sources/WhoopStore/ImuChunkCatalog.swift
new file mode 100644
index 0000000000..c2bf14ea77
--- /dev/null
+++ b/Packages/WhoopStore/Sources/WhoopStore/ImuChunkCatalog.swift
@@ -0,0 +1,62 @@
+import Foundation
+import GRDB
+
+public struct ImuChunkMeta: Codable, Equatable, Sendable {
+ public let id: String
+ public let deviceId: String
+ public let startTs: Int
+ public let endTs: Int
+ public let sampleCount: Int
+ public let sampleRate: Int
+ public let formatVersion: Int
+ public let codec: String
+ public let relativePath: String
+ public let byteSize: Int
+ public let sha256: String
+ public let createdAt: Int
+ public let pinnedUntil: Int?
+
+ public init(id: String, deviceId: String, startTs: Int, endTs: Int, sampleCount: Int,
+ sampleRate: Int, formatVersion: Int, codec: String, relativePath: String,
+ byteSize: Int, sha256: String, createdAt: Int, pinnedUntil: Int?) {
+ self.id = id; self.deviceId = deviceId; self.startTs = startTs; self.endTs = endTs
+ self.sampleCount = sampleCount; self.sampleRate = sampleRate; self.formatVersion = formatVersion
+ self.codec = codec; self.relativePath = relativePath; self.byteSize = byteSize
+ self.sha256 = sha256; self.createdAt = createdAt; self.pinnedUntil = pinnedUntil
+ }
+}
+
+extension WhoopStore {
+ public func upsertImuChunk(_ chunk: ImuChunkMeta) async throws {
+ try syncWrite { db in
+ try db.execute(sql: """
+ INSERT INTO imuChunk
+ (id, deviceId, startTs, endTs, sampleCount, sampleRate, formatVersion, codec,
+ relativePath, byteSize, sha256, createdAt, pinnedUntil)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ ON CONFLICT(id) DO UPDATE SET
+ relativePath=excluded.relativePath, byteSize=excluded.byteSize,
+ sha256=excluded.sha256, pinnedUntil=excluded.pinnedUntil
+ """, arguments: [chunk.id, chunk.deviceId, chunk.startTs, chunk.endTs,
+ chunk.sampleCount, chunk.sampleRate, chunk.formatVersion, chunk.codec,
+ chunk.relativePath, chunk.byteSize, chunk.sha256, chunk.createdAt,
+ chunk.pinnedUntil])
+ }
+ }
+
+ public func imuChunks(deviceId: String, from: Int, to: Int) async throws -> [ImuChunkMeta] {
+ try syncRead { db in
+ try Row.fetchAll(db, sql: """
+ SELECT * FROM imuChunk
+ WHERE deviceId = ? AND endTs >= ? AND startTs <= ? ORDER BY startTs
+ """, arguments: [deviceId, from, to]).map { row in
+ ImuChunkMeta(id: row["id"], deviceId: row["deviceId"], startTs: row["startTs"],
+ endTs: row["endTs"], sampleCount: row["sampleCount"],
+ sampleRate: row["sampleRate"], formatVersion: row["formatVersion"],
+ codec: row["codec"], relativePath: row["relativePath"],
+ byteSize: row["byteSize"], sha256: row["sha256"],
+ createdAt: row["createdAt"], pinnedUntil: row["pinnedUntil"])
+ }
+ }
+ }
+}
diff --git a/Packages/WhoopStore/Sources/WhoopStore/StreamStore.swift b/Packages/WhoopStore/Sources/WhoopStore/StreamStore.swift
index 43bf0b7fe4..9c6e2ae4ab 100644
--- a/Packages/WhoopStore/Sources/WhoopStore/StreamStore.swift
+++ b/Packages/WhoopStore/Sources/WhoopStore/StreamStore.swift
@@ -59,8 +59,8 @@ extension WhoopStore {
}
/// #423 rolling retention for the raw-IMU capture table (twin of Kotlin `RAW_IMU_RETENTION_ROWS`):
- /// ~1 h at 1 row/strap-second (~4 MB) hard-caps the table during a multi-day offload replay.
- public static let rawImuRetentionRows = 3600
+ /// 36 h at 1 row/strap-second (~155 MB uncompressed): retrospective activity/workout window.
+ public static let rawImuRetentionRows = 36 * 60 * 60
/// v31 rolling retention for the v18 aux-slot table (twin of Kotlin `V18_AUX_RETENTION_ROWS`).
///
@@ -123,6 +123,20 @@ extension WhoopStore {
}
}
+ public func rawImuSamples(deviceId: String, from: Int, to: Int, limit: Int) async throws
+ -> [(ts: Int, cols: [Int16])] {
+ try syncRead { db in
+ try Row.fetchAll(db, sql: """
+ SELECT ts, samples FROM rawImuSample
+ WHERE deviceId = ? AND ts >= ? AND ts <= ? ORDER BY ts LIMIT ?
+ """, arguments: [deviceId, from, to, limit]).map { row in
+ let ts: Int = row["ts"]
+ let data: Data = row["samples"]
+ return (ts, WhoopStore.unpackImuColumns(data))
+ }
+ }
+ }
+
/// Idempotent upsert of decoded streams by natural key. Returns the number of rows
/// ACTUALLY inserted per stream (0 for rows that already existed).
///
diff --git a/Packages/WhoopStore/Tests/WhoopStoreTests/Resources/schema_oracle.json b/Packages/WhoopStore/Tests/WhoopStoreTests/Resources/schema_oracle.json
index 4a19cda30f..bef71e5b5b 100644
--- a/Packages/WhoopStore/Tests/WhoopStoreTests/Resources/schema_oracle.json
+++ b/Packages/WhoopStore/Tests/WhoopStoreTests/Resources/schema_oracle.json
@@ -1,6 +1,6 @@
{
"_readme": "SHARED Room<->GRDB SCHEMA ORACLE (#775). Two byte-identical copies: Packages/WhoopStore/Tests/WhoopStoreTests/Resources/schema_oracle.json and android/app/src/test/resources/schema_oracle.json. SchemaOracleTests.swift compares GRDB's PRAGMA table_info/index_list against it; SchemaOracleTest.kt compares Room's exported schema JSON against it. `columns` is the iOS/GRDB shape in GRDB column order (macOS is the reference implementation); every way Android differs is spelled out in an `android` / `iosAbsent` / `androidColumnOrder` override naming a key in `divergenceReasons`. Adding a column, reordering one, or changing a type/nullability on one platform only fails both suites until it is either fixed or written down here.",
- "roomVersion": 33,
+ "roomVersion": 34,
"grdbMigrations": [
"v1",
"v2",
@@ -40,7 +40,8 @@
"v36-whoop-caps-no-spo2",
"v37-daily-avg-sdnn",
"v38-apple-step-hour",
- "v39-ppg-burst-index"
+ "v39-ppg-burst-index",
+ "v40-imu-chunk-catalog"
],
"divergenceReasons": {
"android-orphan-synced-column": "REAL COLUMN DRIFT: `synced` exists on Android only. GRDB's v10 note is explicit that stepSample gets no `synced` column ('unused; see StreamStore'), and v12's ppgHrSample never had one either; the Room entities copied the flag from the older per-second entities. Nothing reads it on Android \u2014 the per-row upload flag belongs to an upload path that does not exist there \u2014 so it is dead width, not divergent data. Removing it needs a Room table rebuild.",
@@ -1252,6 +1253,29 @@
],
"indices": []
},
+ "imuChunk": {
+ "platform": "both",
+ "columns": [
+ { "name": "id", "affinity": "TEXT", "notNull": false, "default": null,
+ "android": { "notNull": true, "divergence": "sqlite-text-pk-nullable" } },
+ { "name": "deviceId", "affinity": "TEXT", "notNull": true, "default": null },
+ { "name": "startTs", "affinity": "INTEGER", "notNull": true, "default": null },
+ { "name": "endTs", "affinity": "INTEGER", "notNull": true, "default": null },
+ { "name": "sampleCount", "affinity": "INTEGER", "notNull": true, "default": null },
+ { "name": "sampleRate", "affinity": "INTEGER", "notNull": true, "default": null },
+ { "name": "formatVersion", "affinity": "INTEGER", "notNull": true, "default": null },
+ { "name": "codec", "affinity": "TEXT", "notNull": true, "default": null },
+ { "name": "relativePath", "affinity": "TEXT", "notNull": true, "default": null },
+ { "name": "byteSize", "affinity": "INTEGER", "notNull": true, "default": null },
+ { "name": "sha256", "affinity": "TEXT", "notNull": true, "default": null },
+ { "name": "createdAt", "affinity": "INTEGER", "notNull": true, "default": null },
+ { "name": "pinnedUntil", "affinity": "INTEGER", "notNull": false, "default": null }
+ ],
+ "primaryKey": ["id"],
+ "indices": [
+ { "name": "index_imuChunk_deviceId_startTs_endTs", "columns": ["deviceId", "startTs", "endTs"], "unique": false }
+ ]
+ },
"rawImuSample": {
"platform": "both",
"columns": [
diff --git a/Strand/BLE/BLEManager.swift b/Strand/BLE/BLEManager.swift
index f180775095..232a334b09 100644
--- a/Strand/BLE/BLEManager.swift
+++ b/Strand/BLE/BLEManager.swift
@@ -1815,11 +1815,28 @@ public final class BLEManager: NSObject, ObservableObject {
await collector?.rawBatches(from: from, to: to) ?? []
}
+ public func groundTruthHistoryCSV(from: Int, to: Int) async -> Data {
+ await collector?.historySensorsCSV(from: from, to: to)
+ ?? Data("stream,unix_s,v1,v2,v3,v4\n".utf8)
+ }
+
@discardableResult
public func deleteGroundTruthRawBatches(from: Int, to: Int) async -> Int {
await collector?.deleteRawBatches(from: from, to: to) ?? 0
}
+ public func groundTruthRawImuSamples(from: Int, to: Int) async -> [(ts: Int, cols: [Int16])] {
+ await collector?.rawImuSamples(from: from, to: to) ?? []
+ }
+
+ public func groundTruthImuChunks(from: Int, to: Int) async -> [ImuChunkMeta] {
+ await collector?.imuChunks(from: from, to: to) ?? []
+ }
+
+ public func registerGroundTruthImuChunk(_ chunk: ImuChunkMeta) async {
+ await collector?.upsertImuChunk(chunk)
+ }
+
/// Send a command to the WHOOP strap.
/// - Parameters:
/// - command: The command to send.
diff --git a/Strand/Collect/Collector.swift b/Strand/Collect/Collector.swift
index 32c9eef273..0fee2e2128 100644
--- a/Strand/Collect/Collector.swift
+++ b/Strand/Collect/Collector.swift
@@ -109,12 +109,49 @@ final class Collector {
return result
}
+ /// Decoded history rows in a stable long-form CSV for arbitrary user-selected export windows.
+ func historySensorsCSV(from: Int, to: Int) async -> Data {
+ guard let store = concreteStore, from <= to else { return Data("stream,unix_s,v1,v2,v3,v4\n".utf8) }
+ let limit = min(max(to - from + 1, 1) * 4, 1_000_000)
+ async let hr = try? store.hrSamples(deviceId: deviceId, from: from, to: to, limit: limit)
+ async let battery = try? store.batterySamples(deviceId: deviceId, from: from, to: to, limit: limit)
+ async let spo2 = try? store.spo2Samples(deviceId: deviceId, from: from, to: to, limit: limit)
+ async let temp = try? store.skinTempSamples(deviceId: deviceId, from: from, to: to, limit: limit)
+ async let steps = try? store.stepSamples(deviceId: deviceId, from: from, to: to, limit: limit)
+ async let resp = try? store.respSamples(deviceId: deviceId, from: from, to: to, limit: limit)
+ async let gravity = try? store.gravitySamples(deviceId: deviceId, from: from, to: to, limit: limit)
+ var lines = ["stream,unix_s,v1,v2,v3,v4"]
+ lines += await (hr ?? []).map { "heart_rate,\($0.ts),\($0.bpm),,," }
+ lines += await (battery ?? []).map { "battery,\($0.ts),\($0.soc.map(String.init) ?? ""),\($0.mv.map(String.init) ?? ""),," }
+ lines += await (spo2 ?? []).map { "spo2_raw,\($0.ts),\($0.red),\($0.ir),," }
+ lines += await (temp ?? []).map { "skin_temp_raw,\($0.ts),\($0.raw),\($0.aux1Raw.map(String.init) ?? ""),\($0.aux2Raw.map(String.init) ?? "")," }
+ lines += await (steps ?? []).map { "steps,\($0.ts),\($0.counter),\($0.activityClass.map(String.init) ?? ""),," }
+ lines += await (resp ?? []).map { "resp_raw,\($0.ts),\($0.raw),,," }
+ lines += await (gravity ?? []).map { "gravity,\($0.ts),\($0.x),\($0.y),\($0.z),\($0.dynAccel.map(String.init) ?? "")" }
+ return Data((lines.joined(separator: "\n") + "\n").utf8)
+ }
+
@discardableResult
func deleteRawBatches(from: Int, to: Int) async -> Int {
guard let store = concreteStore else { return 0 }
return (try? await store.deleteRawBatches(deviceId: deviceId, from: from, to: to)) ?? 0
}
+ func rawImuSamples(from: Int, to: Int) async -> [(ts: Int, cols: [Int16])] {
+ guard let store = concreteStore else { return [] }
+ let limit = min(max(to - from + 1, 1), 7 * 24 * 60 * 60)
+ return (try? await store.rawImuSamples(deviceId: deviceId, from: from, to: to, limit: limit)) ?? []
+ }
+
+ func imuChunks(from: Int, to: Int) async -> [ImuChunkMeta] {
+ guard let store = concreteStore else { return [] }
+ return (try? await store.imuChunks(deviceId: deviceId, from: from, to: to)) ?? []
+ }
+
+ func upsertImuChunk(_ chunk: ImuChunkMeta) async {
+ try? await concreteStore?.upsertImuChunk(chunk)
+ }
+
/// Max persisted HR sample ts (the biometric "data frontier" for the stuck-strap watchdog).
/// nil if there's no concrete store or nothing persisted yet. Mirrors storageStats().
func latestHRSampleTs() async -> Int? {
diff --git a/Strand/Collect/ImuChunkArchiveStore.swift b/Strand/Collect/ImuChunkArchiveStore.swift
new file mode 100644
index 0000000000..78e08f15e9
--- /dev/null
+++ b/Strand/Collect/ImuChunkArchiveStore.swift
@@ -0,0 +1,75 @@
+import CryptoKit
+import Foundation
+import WhoopStore
+
+/// Apple twin of Android ImuChunkStore. ZIP entries and samples.bin layout are platform-identical.
+@MainActor
+final class ImuChunkArchiveStore {
+ static let formatVersion = 1
+ static let sampleRate = 100
+ private let directory: URL
+
+ init() {
+ let base = (try? FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask,
+ appropriateFor: nil, create: true))
+ ?? FileManager.default.temporaryDirectory
+ directory = base.appendingPathComponent("OpenWhoop/imu-chunks", isDirectory: true)
+ try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
+ }
+
+ func pin(deviceId: String, from: Int, to: Int, ble: BLEManager) async -> [ImuChunkMeta] {
+ let existing = await ble.groundTruthImuChunks(from: from, to: to)
+ .filter { $0.pinnedUntil == Int.max && file($0).isFileURL && FileManager.default.fileExists(atPath: file($0).path) }
+ if existing.contains(where: { $0.startTs <= from && $0.endTs >= to }) { return existing }
+ let rows = await ble.groundTruthRawImuSamples(from: from, to: to)
+ guard !rows.isEmpty else { return existing }
+
+ var samples = Data()
+ for row in rows {
+ samples.appendBigEndian(Int64(row.ts))
+ samples.appendBigEndian(Int32(row.cols.count * 2))
+ for value in row.cols {
+ samples.append(UInt8(truncatingIfNeeded: value))
+ samples.append(UInt8(truncatingIfNeeded: value >> 8))
+ }
+ }
+ let manifest: [String: Any] = [
+ "format": "NOOPIMU", "version": Self.formatVersion,
+ "sample_rate_hz": Self.sampleRate,
+ "axes": ["ax", "ay", "az", "gx", "gy", "gz"],
+ "layout": "column-major-int16-le", "row_count": rows.count,
+ "start_ts": rows.first!.ts, "end_ts": rows.last!.ts,
+ ]
+ guard let manifestData = try? JSONSerialization.data(withJSONObject: manifest, options: [.prettyPrinted, .sortedKeys]),
+ let staged = FileExport.zipData(entries: [
+ .init(name: "manifest.json", data: manifestData), .init(name: "samples.bin", data: samples),
+ ], baseName: UUID().uuidString) else { return [] }
+ let id = UUID().uuidString
+ let destination = directory.appendingPathComponent("\(id).imuc")
+ do { try FileManager.default.moveItem(at: staged, to: destination) } catch { return [] }
+ let bytes = (try? Data(contentsOf: destination)) ?? Data()
+ let base = directory.deletingLastPathComponent().deletingLastPathComponent()
+ let relative = destination.path.replacingOccurrences(of: base.path + "/", with: "")
+ let chunk = ImuChunkMeta(
+ id: id, deviceId: deviceId, startTs: rows.first!.ts, endTs: rows.last!.ts,
+ sampleCount: rows.count * Self.sampleRate, sampleRate: Self.sampleRate,
+ formatVersion: Self.formatVersion, codec: "zip-deflate", relativePath: relative,
+ byteSize: bytes.count, sha256: SHA256.hash(data: bytes).map { String(format: "%02x", $0) }.joined(),
+ createdAt: Int(Date().timeIntervalSince1970), pinnedUntil: Int.max)
+ await ble.registerGroundTruthImuChunk(chunk)
+ return await ble.groundTruthImuChunks(from: from, to: to)
+ .filter { FileManager.default.fileExists(atPath: file($0).path) }
+ }
+
+ func file(_ chunk: ImuChunkMeta) -> URL {
+ let base = directory.deletingLastPathComponent().deletingLastPathComponent()
+ return base.appendingPathComponent(chunk.relativePath)
+ }
+}
+
+private extension Data {
+ mutating func appendBigEndian(_ value: T) {
+ var v = value.bigEndian
+ Swift.withUnsafeBytes(of: &v) { append(contentsOf: $0) }
+ }
+}
diff --git a/Strand/Collect/RawDataSessionStore.swift b/Strand/Collect/RawDataSessionStore.swift
index 77189fd46a..d38ffd81b4 100644
--- a/Strand/Collect/RawDataSessionStore.swift
+++ b/Strand/Collect/RawDataSessionStore.swift
@@ -28,6 +28,9 @@ final class RawDataSessionStore: ObservableObject {
let deviceId: String
let startedAtMs: Int64
var endedAtMs: Int64?
+ var capturedStartedAtMs: Int64?
+ var capturedEndedAtMs: Int64?
+ var keepRaw: Bool?
var steps: Int
var stairs: Int
var excludedWindows: Int
@@ -41,6 +44,8 @@ final class RawDataSessionStore: ObservableObject {
lhs.id == rhs.id && lhs.endedAtMs == rhs.endedAtMs && lhs.steps == rhs.steps
&& lhs.stairs == rhs.stairs && lhs.excludedWindows == rhs.excludedWindows
&& lhs.comment == rhs.comment && lhs.exported == rhs.exported
+ && lhs.capturedStartedAtMs == rhs.capturedStartedAtMs
+ && lhs.capturedEndedAtMs == rhs.capturedEndedAtMs
}
}
@@ -76,7 +81,8 @@ final class RawDataSessionStore: ObservableObject {
let millis = Int64(now.timeIntervalSince1970 * 1_000)
let started = Event(atMs: millis, kind: .start, stepsTotal: 0, stairsTotal: 0)
let session = Session(id: String(millis), deviceId: deviceId, startedAtMs: millis,
- endedAtMs: nil, steps: 0, stairs: 0, excludedWindows: 0,
+ endedAtMs: nil, capturedStartedAtMs: millis, capturedEndedAtMs: nil,
+ keepRaw: true, steps: 0, stairs: 0, excludedWindows: 0,
comment: "", exported: false, events: [started])
sessions.insert(session, at: 0)
persist(session)
@@ -87,11 +93,43 @@ final class RawDataSessionStore: ObservableObject {
mutateActive { session in
let millis = Int64(now.timeIntervalSince1970 * 1_000)
session.endedAtMs = millis
+ session.capturedEndedAtMs = millis
session.events.append(Event(atMs: millis, kind: .stop,
stepsTotal: session.steps, stairsTotal: session.stairs))
}
}
+ @discardableResult
+ func createHistorical(deviceId: String, from: Date, to: Date) -> Session? {
+ let fromMs = Int64(from.timeIntervalSince1970 * 1_000)
+ let toMs = Int64(to.timeIntervalSince1970 * 1_000)
+ guard validRange(fromMs, toMs) else { return nil }
+ let id = String(Int64(Date().timeIntervalSince1970 * 1_000))
+ let events = [Event(atMs: fromMs, kind: .start, stepsTotal: 0, stairsTotal: 0),
+ Event(atMs: toMs, kind: .stop, stepsTotal: 0, stairsTotal: 0)]
+ let session = Session(id: id, deviceId: deviceId, startedAtMs: fromMs, endedAtMs: toMs,
+ capturedStartedAtMs: nil, capturedEndedAtMs: nil, keepRaw: true,
+ steps: 0, stairs: 0, excludedWindows: 0, comment: "",
+ exported: false, events: events)
+ sessions.insert(session, at: 0); persist(session)
+ return session
+ }
+
+ func setRange(sessionId: String, from: Date, to: Date) {
+ let fromMs = Int64(from.timeIntervalSince1970 * 1_000)
+ let toMs = Int64(to.timeIntervalSince1970 * 1_000)
+ guard validRange(fromMs, toMs) else { return }
+ mutate(sessionId) { session in
+ guard !session.active else { return }
+ session = Session(id: session.id, deviceId: session.deviceId, startedAtMs: fromMs,
+ endedAtMs: toMs, capturedStartedAtMs: session.capturedStartedAtMs,
+ capturedEndedAtMs: session.capturedEndedAtMs, keepRaw: session.keepRaw,
+ steps: session.steps, stairs: session.stairs,
+ excludedWindows: session.excludedWindows, comment: session.comment,
+ exported: false, events: session.events)
+ }
+ }
+
func record(_ kind: LabelKind, now: Date = Date()) {
guard kind == .step || kind == .stair else { return }
mutateActive { session in
@@ -156,9 +194,13 @@ final class RawDataSessionStore: ObservableObject {
private func file(_ id: String) -> URL { directory.appendingPathComponent("session-\(id).json") }
+ private func validRange(_ from: Int64, _ to: Int64) -> Bool {
+ from > 0 && from <= to && to - from <= 7 * 24 * 60 * 60 * 1_000
+ }
+
func exportEntries(for session: Session,
raw: [(RawBatchMeta, [[UInt8]])]) -> [FileExport.BundleEntry] {
- let meta: [String: Any] = [
+ var meta: [String: Any] = [
"schema_version": 3, "capture_kind": "whoop_5mg_raw_data",
"session_id": session.id, "device_id": session.deviceId,
"started_at_ms": session.startedAtMs, "ended_at_ms": session.endedAtMs ?? session.startedAtMs,
@@ -169,6 +211,8 @@ final class RawDataSessionStore: ObservableObject {
"app_version": Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "?",
"manual_labels": "optional; on-screen step and stair controls",
]
+ if let value = session.capturedStartedAtMs { meta["captured_started_at_ms"] = value }
+ if let value = session.capturedEndedAtMs { meta["captured_ended_at_ms"] = value }
let metaData = (try? JSONSerialization.data(withJSONObject: meta, options: [.prettyPrinted, .sortedKeys])) ?? Data()
let eventData = session.events.compactMap { event -> Data? in
var row: [String: Any] = ["at_ms": event.atMs, "kind": event.kind.rawValue,
diff --git a/Strand/Screens/RawDataCollectorView.swift b/Strand/Screens/RawDataCollectorView.swift
index 7613b590aa..86e36b5c79 100644
--- a/Strand/Screens/RawDataCollectorView.swift
+++ b/Strand/Screens/RawDataCollectorView.swift
@@ -6,6 +6,7 @@ struct RawDataCollectorView: View {
@EnvironmentObject private var model: AppModel
@EnvironmentObject private var live: LiveState
@StateObject private var store = RawDataSessionStore()
+ private let archive = ImuChunkArchiveStore()
@State private var excludeMinutes = "5"
@State private var exportingId: String?
@@ -13,6 +14,8 @@ struct RawDataCollectorView: View {
@State private var confirmDeleteAll = false
@State private var exportError: String?
@State private var rawBatchCounts: [String: Int] = [:]
+ @State private var historicalFrom = Date().addingTimeInterval(-3_600)
+ @State private var historicalTo = Date()
var body: some View {
ScreenScaffold(
@@ -24,6 +27,7 @@ struct RawDataCollectorView: View {
coverageCard
labelsCard
controls
+ historicalRangeCard
sessionsSection
}
}
@@ -136,6 +140,24 @@ struct RawDataCollectorView: View {
}
}
+ private var historicalRangeCard: some View {
+ StrandCard {
+ VStack(alignment: .leading, spacing: NoopMetrics.space3) {
+ Text("Historical export window").font(StrandFont.headline)
+ Text("Create a session from synchronized history without starting a live capture. 100 Hz coverage is included wherever it still exists in the rolling buffer.")
+ .font(StrandFont.caption).foregroundStyle(StrandPalette.textTertiary)
+ DatePicker("From", selection: $historicalFrom)
+ DatePicker("To", selection: $historicalTo, in: historicalFrom...)
+ NoopButton("Add historical session", systemImage: "clock.arrow.circlepath",
+ kind: .secondary, fullWidth: true) {
+ _ = store.createHistorical(deviceId: model.ble.deviceId,
+ from: historicalFrom, to: historicalTo)
+ }
+ .disabled(historicalTo <= historicalFrom || historicalTo.timeIntervalSince(historicalFrom) > 7 * 86_400)
+ }
+ }
+ }
+
private var sessionsSection: some View {
VStack(alignment: .leading, spacing: NoopMetrics.space3) {
Text("Recorded sessions").font(StrandFont.title2).foregroundStyle(StrandPalette.textPrimary)
@@ -155,6 +177,18 @@ struct RawDataCollectorView: View {
StrandCard {
VStack(alignment: .leading, spacing: NoopMetrics.space3) {
Text(Self.range(session)).font(StrandFont.headline).foregroundStyle(StrandPalette.textPrimary)
+ if !session.active, let endMs = session.endedAtMs {
+ DatePicker("From", selection: Binding(
+ get: { Date(timeIntervalSince1970: Double(session.startedAtMs) / 1_000) },
+ set: { store.setRange(sessionId: session.id, from: $0,
+ to: Date(timeIntervalSince1970: Double(endMs) / 1_000)) }
+ ))
+ DatePicker("To", selection: Binding(
+ get: { Date(timeIntervalSince1970: Double(endMs) / 1_000) },
+ set: { store.setRange(sessionId: session.id,
+ from: Date(timeIntervalSince1970: Double(session.startedAtMs) / 1_000), to: $0) }
+ ))
+ }
Text("\(session.steps) steps · \(session.stairs) stairs · \(session.excludedWindows) excluded periods")
.font(StrandFont.caption).foregroundStyle(StrandPalette.textSecondary)
Text(session.active ? "Export status: recording"
@@ -202,9 +236,25 @@ struct RawDataCollectorView: View {
private func export(_ session: RawDataSessionStore.Session) async {
guard let end = session.endedAtMs else { return }
exportingId = session.id
- let raw = await model.ble.groundTruthRawBatches(from: Int(session.startedAtMs / 1_000),
- to: Int(end / 1_000))
- let entries = store.exportEntries(for: session, raw: raw)
+ let from = Int(session.startedAtMs / 1_000), to = Int(end / 1_000)
+ let chunks = await archive.pin(deviceId: session.deviceId, from: from, to: to, ble: model.ble)
+ let history = await model.ble.groundTruthHistoryCSV(from: from, to: to)
+ var entries = store.exportEntries(for: session, raw: [])
+ entries.append(.init(name: "history-sensors.csv", data: history))
+ let coverage: [String: Any] = [
+ "requested_start_ts": from, "requested_end_ts": to,
+ "complete": Self.covers(chunks.map { ($0.startTs, $0.endTs) }, from: from, to: to),
+ "chunks": chunks.map { ["start_ts": $0.startTs, "end_ts": $0.endTs,
+ "sample_count": $0.sampleCount, "sha256": $0.sha256] }
+ ]
+ if let data = try? JSONSerialization.data(withJSONObject: coverage, options: [.prettyPrinted, .sortedKeys]) {
+ entries.append(.init(name: "imu-coverage.json", data: data))
+ }
+ for chunk in chunks {
+ if let data = try? Data(contentsOf: archive.file(chunk)) {
+ entries.append(.init(name: "imu/\(chunk.id).imuc", data: data))
+ }
+ }
let result = await FileExport.exportBundle(entries: entries,
suggestedName: "noop-ground-truth-\(session.id).zip")
if result == nil { exportError = "The export file could not be created or shared." }
@@ -213,9 +263,8 @@ struct RawDataCollectorView: View {
}
private func delete(_ session: RawDataSessionStore.Session) async {
- guard let end = session.endedAtMs else { return }
- _ = await model.ble.deleteGroundTruthRawBatches(from: Int(session.startedAtMs / 1_000),
- to: Int(end / 1_000))
+ guard session.endedAtMs != nil else { return }
+ // Raw chunks may overlap another session; retention cleanup owns physical deletion.
store.removeMetadata(session.id)
rawBatchCounts[session.id] = nil
}
@@ -242,4 +291,14 @@ struct RawDataCollectorView: View {
let end = session.endedAtMs.map(time) ?? "…"
return "\(date) · \(time(session.startedAtMs))–\(end)"
}
+
+ private static func covers(_ intervals: [(Int, Int)], from: Int, to: Int) -> Bool {
+ var cursor = from
+ for (start, end) in intervals.sorted(by: { $0.0 < $1.0 }) {
+ if start > cursor { return false }
+ if end >= cursor { cursor = end + 1 }
+ if cursor > to { return true }
+ }
+ return cursor > to
+ }
}
diff --git a/StrandTests/RawDataCollectorParityTests.swift b/StrandTests/RawDataCollectorParityTests.swift
new file mode 100644
index 0000000000..848251d072
--- /dev/null
+++ b/StrandTests/RawDataCollectorParityTests.swift
@@ -0,0 +1,49 @@
+import XCTest
+@testable import Strand
+
+/// Feature-level parity guard for the Android and Apple raw-data collectors. The shared oracle lists
+/// every user-visible/session-lifecycle capability and source markers on both implementations.
+final class RawDataCollectorParityTests: XCTestCase {
+ private struct Oracle: Decodable {
+ let schemaVersion: Int
+ let capabilities: [String: Capability]
+ enum CodingKeys: String, CodingKey { case schemaVersion = "schema_version", capabilities }
+ }
+ private struct Capability: Decodable { let swift: [String]; let kotlin: [String] }
+
+ private var repoRoot: URL {
+ URL(fileURLWithPath: #filePath).deletingLastPathComponent().deletingLastPathComponent()
+ }
+
+ private func oracleData() throws -> Data {
+ let url = try XCTUnwrap(Bundle(for: Self.self).url(forResource: "raw_data_collector_parity",
+ withExtension: "json"))
+ return try Data(contentsOf: url)
+ }
+
+ func testAppleSurfaceStillImplementsEveryDeclaredCapability() throws {
+ let oracle = try JSONDecoder().decode(Oracle.self, from: oracleData())
+ XCTAssertEqual(oracle.schemaVersion, 1)
+ let paths = [
+ "Strand/Collect/RawDataSessionStore.swift", "Strand/Collect/Collector.swift",
+ "Strand/BLE/BLEManager.swift", "Strand/Screens/RawDataCollectorView.swift",
+ "Strand/Collect/ImuChunkArchiveStore.swift",
+ "Packages/WhoopStore/Sources/WhoopStore/ImuChunkCatalog.swift",
+ "Packages/WhoopStore/Sources/WhoopStore/StreamStore.swift",
+ "Packages/WhoopStore/Sources/WhoopStore/RawOutbox.swift",
+ ]
+ let source = try paths.map { try String(contentsOf: repoRoot.appendingPathComponent($0)) }
+ .joined(separator: "\n")
+ for (name, capability) in oracle.capabilities {
+ for marker in capability.swift {
+ XCTAssertTrue(source.contains(marker), "Apple collector lost \(name) marker: \(marker)")
+ }
+ }
+ }
+
+ func testAndroidAndAppleOracleCopiesAreByteIdentical() throws {
+ let android = repoRoot.appendingPathComponent("android/app/src/test/resources/raw_data_collector_parity.json")
+ XCTAssertEqual(oracleData(), try Data(contentsOf: android),
+ "Raw-data collector parity oracle copies must change together")
+ }
+}
diff --git a/StrandTests/Resources/raw_data_collector_parity.json b/StrandTests/Resources/raw_data_collector_parity.json
new file mode 100644
index 0000000000..3129d3d7e9
--- /dev/null
+++ b/StrandTests/Resources/raw_data_collector_parity.json
@@ -0,0 +1,73 @@
+{
+ "schema_version": 1,
+ "capabilities": {
+ "start_stop": {
+ "swift": ["startGroundTruthRawCapture", "stopGroundTruthRawCapture"],
+ "kotlin": ["startGroundTruthImuCapture", "stopGroundTruthImuCapture"]
+ },
+ "persisted_sessions": {
+ "swift": ["RawDataSessionStore", "persist(session)"],
+ "kotlin": ["GroundTruthCollector", "sessionStartKey"]
+ },
+ "manual_step_and_stair_labels": {
+ "swift": ["store.record(.step)", "store.record(.stair)"],
+ "kotlin": ["KIND_STEP", "KIND_STAIR"]
+ },
+ "undo_last_label": {
+ "swift": ["func undo(", "Undo last click"],
+ "kotlin": ["fun undo(", "ground_truth_undo"]
+ },
+ "exclude_recent_period": {
+ "swift": ["excludeLast(minutes:", "Ignore recent period"],
+ "kotlin": ["excludeLastMinutes(", "ground_truth_exclude_recent"]
+ },
+ "session_comments": {
+ "swift": ["setComment(", "Session comment"],
+ "kotlin": ["setComment(", "ground_truth_comment"]
+ },
+ "from_to_time": {
+ "swift": ["func range(", "endedAtMs"],
+ "kotlin": ["sessionTimeRange(", "endedAtMs"]
+ },
+ "session_export": {
+ "swift": ["exportEntries(for:", "Export session"],
+ "kotlin": ["suspend fun export(", "ground_truth_export"]
+ },
+ "historical_time_windows": {
+ "swift": ["createHistorical(deviceId:", "Historical export window"],
+ "kotlin": ["createHistoricalSession(", "ground_truth_add_historical"]
+ },
+ "editable_analysis_range": {
+ "swift": ["func setRange(sessionId:", "DatePicker(\"From\""],
+ "kotlin": ["fun setSessionRange(", "ground_truth_edit_start"]
+ },
+ "bounded_36h_imu_cache": {
+ "swift": ["rawImuRetentionRows = 36 * 60 * 60"],
+ "kotlin": ["RAW_IMU_RETENTION_ROWS = 36 * 60 * 60"]
+ },
+ "durable_imu_chunk_catalog": {
+ "swift": ["ImuChunkArchiveStore", "ImuChunkMeta"],
+ "kotlin": ["ImuChunkStore", "ImuChunkEntity"]
+ },
+ "explicit_imu_coverage": {
+ "swift": ["imu-coverage.json", "private static func covers"],
+ "kotlin": ["imu_100hz_complete", "private fun covers"]
+ },
+ "delete_one_and_all_with_confirmation": {
+ "swift": ["Delete this session?", "Delete all sessions?", "deleteGroundTruthRawBatches"],
+ "kotlin": ["ground_truth_delete_session_title", "ground_truth_delete_all_title", "deleteAllSessions"]
+ },
+ "active_session_delete_guard": {
+ "swift": ["active != true", ".disabled(store.active != nil)"],
+ "kotlin": ["current.active && current.sessionId == sessionId", "sessions.none { it.active }"]
+ },
+ "bluetooth_reconnect_rearm": {
+ "swift": ["onChangeCompat(of: live.bonded)", "rawCaptureInFlight = false"],
+ "kotlin": ["LaunchedEffect(state.active, state.sessionId, live.connected)", "startGroundTruthImuCapture"]
+ },
+ "raw_data_removed_with_session": {
+ "swift": ["deleteRawBatches(deviceId:", "capturedAt >= ? AND capturedAt <= ?"],
+ "kotlin": ["realtime-imu-$sessionId.bin", "noop-ground-truth-$sessionId.zip"]
+ }
+ }
+}
diff --git a/android/app/src/main/java/com/noop/data/Entities.kt b/android/app/src/main/java/com/noop/data/Entities.kt
index 082a796fb3..9dd6d58017 100644
--- a/android/app/src/main/java/com/noop/data/Entities.kt
+++ b/android/app/src/main/java/com/noop/data/Entities.kt
@@ -3,6 +3,7 @@ package com.noop.data
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.Index
+import androidx.room.PrimaryKey
/*
* Room entities mirroring the verified GRDB schema in
@@ -691,6 +692,27 @@ data class RawImuSampleEntity(
}
}
+/** Catalog row for one immutable, app-private IMU archive chunk. Payload bytes live outside SQLite. */
+@Entity(
+ tableName = "imuChunk",
+ indices = [Index(value = ["deviceId", "startTs", "endTs"])],
+)
+data class ImuChunkEntity(
+ @PrimaryKey val id: String,
+ val deviceId: String,
+ val startTs: Long,
+ val endTs: Long,
+ val sampleCount: Int,
+ val sampleRate: Int,
+ val formatVersion: Int,
+ val codec: String,
+ val relativePath: String,
+ val byteSize: Long,
+ val sha256: String,
+ val createdAt: Long,
+ val pinnedUntil: Long?,
+)
+
/**
* One Live Session (silent guardian) record (v22 / MIGRATION_15_16). Natural key (deviceId, startTs).
* `endTs` is null while the session is still in progress. Fields are declared in the SAME order as the
diff --git a/android/app/src/main/java/com/noop/data/WhoopDao.kt b/android/app/src/main/java/com/noop/data/WhoopDao.kt
index 818d9f7cfd..f470ac2a50 100644
--- a/android/app/src/main/java/com/noop/data/WhoopDao.kt
+++ b/android/app/src/main/java/com/noop/data/WhoopDao.kt
@@ -163,6 +163,18 @@ interface WhoopDao : DeviceRegistryDao {
)
suspend fun rawImuSamples(deviceId: String, from: Long, to: Long, limit: Int): List
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun upsertImuChunk(row: ImuChunkEntity)
+
+ @Query("SELECT * FROM imuChunk WHERE deviceId = :deviceId AND endTs >= :from AND startTs <= :to ORDER BY startTs")
+ suspend fun imuChunks(deviceId: String, from: Long, to: Long): List
+
+ @Query("DELETE FROM imuChunk WHERE id = :id")
+ suspend fun deleteImuChunk(id: String)
+
+ @Query("SELECT * FROM imuChunk WHERE pinnedUntil IS NULL AND endTs < :cutoff ORDER BY endTs")
+ suspend fun expiredImuChunks(cutoff: Long): List
+
// MARK: - Server-derived caches (latest value wins)
@Upsert
diff --git a/android/app/src/main/java/com/noop/data/WhoopDatabase.kt b/android/app/src/main/java/com/noop/data/WhoopDatabase.kt
index 09558f0158..6dd2b055fb 100644
--- a/android/app/src/main/java/com/noop/data/WhoopDatabase.kt
+++ b/android/app/src/main/java/com/noop/data/WhoopDatabase.kt
@@ -50,10 +50,11 @@ import androidx.sqlite.db.SupportSQLiteDatabase
LiveSessionRow::class,
PpgWaveformSampleEntity::class,
RawImuSampleEntity::class,
+ ImuChunkEntity::class,
V18AuxSampleEntity::class,
AppleStepHour::class,
],
- version = 33,
+ version = 34,
// #775: ON so Room's KSP processor writes the generated schema (every table's exact `CREATE TABLE`,
// columns in declaration order with affinity/NOT NULL/default, PK and indices) as JSON. That export
// is what lets a plain JVM test — no device, no Robolectric — read Android's REAL schema and compare
@@ -70,7 +71,7 @@ abstract class WhoopDatabase : RoomDatabase() {
const val DB_NAME = "noop_whoop.db"
/** Room schema version — MUST equal the `@Database(version = …)` above. Surfaced in the backup
* manifest (#1410) so an export states its schema. Bump both together on a migration. */
- const val SCHEMA_VERSION = 33
+ const val SCHEMA_VERSION = 34
@Volatile
private var instance: WhoopDatabase? = null
@@ -899,6 +900,21 @@ abstract class WhoopDatabase : RoomDatabase() {
}
}
+ internal val IMU_CHUNK_MIGRATION_SQL: List = listOf(
+ "CREATE TABLE IF NOT EXISTS `imuChunk` (`id` TEXT NOT NULL, `deviceId` TEXT NOT NULL, " +
+ "`startTs` INTEGER NOT NULL, `endTs` INTEGER NOT NULL, `sampleCount` INTEGER NOT NULL, " +
+ "`sampleRate` INTEGER NOT NULL, `formatVersion` INTEGER NOT NULL, `codec` TEXT NOT NULL, " +
+ "`relativePath` TEXT NOT NULL, `byteSize` INTEGER NOT NULL, `sha256` TEXT NOT NULL, " +
+ "`createdAt` INTEGER NOT NULL, `pinnedUntil` INTEGER, PRIMARY KEY(`id`))",
+ "CREATE INDEX IF NOT EXISTS `index_imuChunk_deviceId_startTs_endTs` ON `imuChunk` (`deviceId`, `startTs`, `endTs`)",
+ )
+
+ internal val MIGRATION_33_34 = object : Migration(33, 34) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ for (stmt in IMU_CHUNK_MIGRATION_SQL) db.execSQL(stmt)
+ }
+ }
+
/**
* Every migration the builder registers, as a VALUE rather than an argument list.
*
@@ -924,7 +940,7 @@ abstract class WhoopDatabase : RoomDatabase() {
MIGRATION_18_19, MIGRATION_19_20, MIGRATION_20_21, MIGRATION_21_22,
MIGRATION_22_23, MIGRATION_23_24, MIGRATION_24_25, MIGRATION_25_26,
MIGRATION_26_27, MIGRATION_27_28, MIGRATION_28_29, MIGRATION_29_30,
- MIGRATION_30_31, MIGRATION_31_32, MIGRATION_32_33,
+ MIGRATION_30_31, MIGRATION_31_32, MIGRATION_32_33, MIGRATION_33_34,
)
diff --git a/android/app/src/main/java/com/noop/data/WhoopRepository.kt b/android/app/src/main/java/com/noop/data/WhoopRepository.kt
index b80e3b4c16..ba989deb21 100644
--- a/android/app/src/main/java/com/noop/data/WhoopRepository.kt
+++ b/android/app/src/main/java/com/noop/data/WhoopRepository.kt
@@ -1021,6 +1021,12 @@ class WhoopRepository(
dao.rawImuSamples(deviceId, from, to, limit)
.map { it.ts to StreamPersistence.unpackImuColumns(it.samples) }
+ suspend fun upsertImuChunk(row: ImuChunkEntity) = dao.upsertImuChunk(row)
+ suspend fun imuChunks(deviceId: String, from: Long, to: Long): List =
+ dao.imuChunks(deviceId, from, to)
+ suspend fun expiredImuChunks(cutoff: Long): List = dao.expiredImuChunks(cutoff)
+ suspend fun deleteImuChunk(id: String) = dao.deleteImuChunk(id)
+
/** Downsampled HR (mean bpm per [bucketSeconds]) for the strap, for the Today 24h trend chart. */
suspend fun hrBuckets(deviceId: String, from: Long, to: Long, bucketSeconds: Long = 300L) =
dao.hrBuckets(deviceId, from, to, bucketSeconds)
@@ -1964,7 +1970,8 @@ class WhoopRepository(
/** #423: rolling retention for the raw-IMU capture table (1 row/strap-second, ~1.2 KB each). One
* hour ≈ 3600 rows ≈ 4 MB caps the table hard, so an enabled capture can never balloon the DB
* during a multi-day offload replay. Instrument-first bounded window; nothing consumes it yet. */
- const val RAW_IMU_RETENTION_ROWS = 3600
+ /** 36 h rolling cache: enough for retrospective activity/workout windows without permanent growth. */
+ const val RAW_IMU_RETENTION_ROWS = 36 * 60 * 60
/**
* v31: rolling retention for the v18 aux-slot table (Swift twin `WhoopStore.v18AuxRetentionRows`).
diff --git a/android/app/src/main/java/com/noop/testcentre/GroundTruthCollector.kt b/android/app/src/main/java/com/noop/testcentre/GroundTruthCollector.kt
index 884b30d8d6..f5824b8168 100644
--- a/android/app/src/main/java/com/noop/testcentre/GroundTruthCollector.kt
+++ b/android/app/src/main/java/com/noop/testcentre/GroundTruthCollector.kt
@@ -46,6 +46,8 @@ class GroundTruthCollector private constructor(private val context: Context) {
val deviceId: String?,
val startedAtMs: Long,
val endedAtMs: Long?,
+ val capturedStartedAtMs: Long?,
+ val capturedEndedAtMs: Long?,
val steps: Int,
val stairs: Int,
val excludedWindows: Int,
@@ -92,6 +94,7 @@ class GroundTruthCollector private constructor(private val context: Context) {
.putString(sessionDeviceKey(id), deviceId)
.putLong(sessionStartKey(id), nowMs)
.putBoolean(sessionExportedKey(id), false)
+ .putBoolean(sessionCaptureKey(id), true)
.apply()
append(id, event(nowMs, "start", 0, 0, noopSteps, null).put("strap_device_id", deviceId))
return snapshot()
@@ -132,13 +135,19 @@ class GroundTruthCollector private constructor(private val context: Context) {
val first = rows.firstOrNull() ?: return@mapNotNull null
val last = rows.last()
val isCurrent = current.sessionId == id
+ val physicalStart = prefs.getLong(sessionStartKey(id), first.optLong("at_ms"))
+ val capturedEnd = rows.lastOrNull { it.optString("kind") == "stop" }?.optLong("at_ms")
+ val hasCapture = prefs.getBoolean(sessionCaptureKey(id),
+ File(directory, "realtime-imu-$id.bin").isFile)
SessionSummary(
id = id,
deviceId = prefs.getString(sessionDeviceKey(id), null)
?: first.optString("strap_device_id").takeIf(String::isNotBlank)
?: current.deviceId.takeIf { isCurrent },
- startedAtMs = prefs.getLong(sessionStartKey(id), first.optLong("at_ms")),
- endedAtMs = rows.lastOrNull { it.optString("kind") == "stop" }?.optLong("at_ms"),
+ startedAtMs = prefs.getLong(sessionUsedStartKey(id), physicalStart),
+ endedAtMs = capturedEnd?.let { prefs.getLong(sessionUsedEndKey(id), it) },
+ capturedStartedAtMs = physicalStart.takeIf { hasCapture },
+ capturedEndedAtMs = capturedEnd.takeIf { hasCapture },
steps = last.optInt("steps_total"),
stairs = last.optInt("stairs_total"),
excludedWindows = rows.count { it.optString("kind") == KIND_EXCLUDE },
@@ -172,6 +181,9 @@ class GroundTruthCollector private constructor(private val context: Context) {
.remove(sessionEndKey(sessionId))
.remove(sessionCommentKey(sessionId))
.remove(sessionExportedKey(sessionId))
+ .remove(sessionUsedStartKey(sessionId))
+ .remove(sessionUsedEndKey(sessionId))
+ .remove(sessionCaptureKey(sessionId))
.apply {
if (current.sessionId == sessionId) clearCurrentSession()
}
@@ -179,6 +191,28 @@ class GroundTruthCollector private constructor(private val context: Context) {
return filesDeleted
}
+ /** Trim the analysis/export interval without rewriting the physical capture provenance. */
+ @Synchronized
+ fun setSessionRange(sessionId: String, fromMs: Long, toMs: Long): Boolean {
+ val session = sessions().firstOrNull { it.id == sessionId } ?: return false
+ if (session.active || fromMs <= 0 || fromMs > toMs || toMs - fromMs > MAX_RANGE_MS) return false
+ prefs.edit().putLong(sessionUsedStartKey(sessionId), fromMs)
+ .putLong(sessionUsedEndKey(sessionId), toMs).apply()
+ return true
+ }
+
+ @Synchronized
+ fun createHistoricalSession(deviceId: String, fromMs: Long, toMs: Long): SessionSummary? {
+ if (fromMs <= 0 || fromMs > toMs || toMs - fromMs > MAX_RANGE_MS) return null
+ val id = System.currentTimeMillis().toString()
+ prefs.edit().putString(sessionDeviceKey(id), deviceId).putLong(sessionStartKey(id), fromMs)
+ .putLong(sessionEndKey(id), toMs).putLong(sessionUsedStartKey(id), fromMs)
+ .putLong(sessionUsedEndKey(id), toMs).putBoolean(sessionCaptureKey(id), false).apply()
+ append(id, event(fromMs, "start", 0, 0, null, null).put("strap_device_id", deviceId))
+ append(id, event(toMs, "stop", 0, 0, null, null))
+ return sessions().firstOrNull { it.id == id }
+ }
+
/** Delete every completed capture. Active captures are deliberately retained. */
@Synchronized
fun deleteAllSessions(): Int {
@@ -261,6 +295,8 @@ class GroundTruthCollector private constructor(private val context: Context) {
// interval; labels and exclusion windows are optional annotations, not a prerequisite for data.
val sensorFrom = if (deviceId == null) 1L else summary.startedAtMs / 1_000L
val sensorTo = if (deviceId == null) 0L else endMs / 1_000L
+ val pinnedChunks = if (deviceId == null) emptyList() else
+ ImuChunkStore(context, repo).pin(deviceId, sensorFrom, sensorTo)
val outDir = File(context.cacheDir, "logs").apply { mkdirs() }
val zip = File(outDir, "noop-ground-truth-$id.zip")
ZipOutputStream(zip.outputStream().buffered()).use { out ->
@@ -271,6 +307,14 @@ class GroundTruthCollector private constructor(private val context: Context) {
put("session_id", id)
if (deviceId != null) put("device_id", deviceId)
put("sensor_export_available", deviceId != null)
+ put("imu_100hz_chunk_count", pinnedChunks.size)
+ put("imu_100hz_complete", covers(pinnedChunks.map { it.startTs to it.endTs }, sensorFrom, sensorTo))
+ put("imu_100hz_coverage", org.json.JSONArray().apply {
+ pinnedChunks.forEach { chunk -> put(JSONObject().apply {
+ put("start_ts", chunk.startTs); put("end_ts", chunk.endTs)
+ put("sample_count", chunk.sampleCount); put("sha256", chunk.sha256)
+ }) }
+ })
put("started_at_ms", summary.startedAtMs)
put("ended_at_ms", endMs)
put("manual_steps", summary.steps)
@@ -342,6 +386,13 @@ class GroundTruthCollector private constructor(private val context: Context) {
realtimeImu.inputStream().use { it.copyTo(out) }
out.closeEntry()
}
+ for (chunk in pinnedChunks) {
+ val file = ImuChunkStore(context, repo).file(chunk)
+ if (!file.isFile) continue
+ out.putNextEntry(ZipEntry("imu/${chunk.id}.imuc"))
+ file.inputStream().use { it.copyTo(out) }
+ out.closeEntry()
+ }
}
prefs.edit().putBoolean(sessionExportedKey(id), true).apply()
if (id == snap.sessionId) prefs.edit().putBoolean("exported", true).apply()
@@ -358,6 +409,16 @@ class GroundTruthCollector private constructor(private val context: Context) {
}, "Export raw-data session").addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
}
+ private fun covers(intervals: List>, from: Long, to: Long): Boolean {
+ var cursor = from
+ for ((start, end) in intervals.sortedBy { it.first }) {
+ if (start > cursor) return false
+ if (end >= cursor) cursor = end + 1
+ if (cursor > to) return true
+ }
+ return cursor > to
+ }
+
private fun eventFile(id: String) = File(directory, "session-$id.jsonl")
private fun append(id: String, value: JSONObject) = eventFile(id).appendText(value.toString() + "\n")
@@ -541,6 +602,9 @@ class GroundTruthCollector private constructor(private val context: Context) {
private fun sessionEndKey(id: String) = "session.$id.endedAtMs"
private fun sessionCommentKey(id: String) = "session.$id.comment"
private fun sessionExportedKey(id: String) = "session.$id.exported"
+ private fun sessionUsedStartKey(id: String) = "session.$id.usedStartedAtMs"
+ private fun sessionUsedEndKey(id: String) = "session.$id.usedEndedAtMs"
+ private fun sessionCaptureKey(id: String) = "session.$id.hasRealtimeCapture"
private fun ZipOutputStream.writerEntry(text: String) {
write(text.toByteArray(Charsets.UTF_8)); closeEntry()
@@ -559,6 +623,7 @@ class GroundTruthCollector private constructor(private val context: Context) {
private const val KIND_UNDO_STAIR = "undo_stair"
private const val KIND_EXCLUDE = "exclude_window"
private const val PREFS = "noop_ground_truth_collector"
+ private const val MAX_RANGE_MS = 7L * 24 * 60 * 60 * 1_000
fun from(context: Context) = GroundTruthCollector(context.applicationContext)
}
diff --git a/android/app/src/main/java/com/noop/testcentre/ImuChunkStore.kt b/android/app/src/main/java/com/noop/testcentre/ImuChunkStore.kt
new file mode 100644
index 0000000000..bd12e8482a
--- /dev/null
+++ b/android/app/src/main/java/com/noop/testcentre/ImuChunkStore.kt
@@ -0,0 +1,93 @@
+package com.noop.testcentre
+
+import android.content.Context
+import com.noop.data.ImuChunkEntity
+import com.noop.data.StreamPersistence
+import com.noop.data.WhoopRepository
+import java.io.BufferedOutputStream
+import java.io.DataOutputStream
+import java.io.File
+import java.io.FileOutputStream
+import java.security.MessageDigest
+import java.util.UUID
+import java.util.zip.ZipEntry
+import java.util.zip.ZipOutputStream
+import org.json.JSONObject
+
+/** Materializes immutable, portable IMU archives from the bounded SQLite rolling cache. */
+class ImuChunkStore(private val context: Context, private val repository: WhoopRepository) {
+ private val directory = File(context.filesDir, "imu-chunks").apply { mkdirs() }
+
+ suspend fun pin(deviceId: String, from: Long, to: Long): List {
+ require(from <= to)
+ val existing = repository.imuChunks(deviceId, from, to)
+ .filter { it.pinnedUntil == Long.MAX_VALUE && File(context.filesDir, it.relativePath).isFile }
+ if (existing.any { it.startTs <= from && it.endTs >= to }) return existing
+ val limit = (to - from + 1).coerceIn(1, MAX_EXPORT_SECONDS).toInt()
+ val rows = repository.rawImuSamples(deviceId, from, to, limit)
+ if (rows.isEmpty()) return existing
+
+ val id = UUID.randomUUID().toString()
+ val final = File(directory, "$id.imuc")
+ val temp = File(directory, "$id.tmp")
+ ZipOutputStream(BufferedOutputStream(FileOutputStream(temp))).use { zip ->
+ zip.putNextEntry(ZipEntry("manifest.json"))
+ zip.write(JSONObject().apply {
+ put("format", "NOOPIMU")
+ put("version", FORMAT_VERSION)
+ put("sample_rate_hz", SAMPLE_RATE)
+ put("axes", listOf("ax", "ay", "az", "gx", "gy", "gz"))
+ put("layout", "column-major-int16-le")
+ put("row_count", rows.size)
+ put("start_ts", rows.first().first)
+ put("end_ts", rows.last().first)
+ }.toString(2).toByteArray())
+ zip.closeEntry()
+ zip.putNextEntry(ZipEntry("samples.bin"))
+ DataOutputStream(zip).let { payload ->
+ for ((ts, columns) in rows) {
+ val packed = StreamPersistence.packImuColumns(columns)
+ payload.writeLong(ts)
+ payload.writeInt(packed.size)
+ payload.write(packed)
+ }
+ }
+ zip.closeEntry()
+ }
+ check(temp.renameTo(final)) { "Could not commit IMU chunk" }
+ val entity = ImuChunkEntity(
+ id = id, deviceId = deviceId, startTs = rows.first().first, endTs = rows.last().first,
+ sampleCount = rows.size * SAMPLE_RATE, sampleRate = SAMPLE_RATE,
+ formatVersion = FORMAT_VERSION, codec = "zip-deflate",
+ relativePath = final.relativeTo(context.filesDir).path, byteSize = final.length(),
+ sha256 = sha256(final), createdAt = System.currentTimeMillis() / 1_000L,
+ pinnedUntil = Long.MAX_VALUE,
+ )
+ repository.upsertImuChunk(entity)
+ return coverage(deviceId, from, to)
+ }
+
+ suspend fun coverage(deviceId: String, from: Long, to: Long): List =
+ repository.imuChunks(deviceId, from, to).filter { File(context.filesDir, it.relativePath).isFile }
+
+ fun file(chunk: ImuChunkEntity) = File(context.filesDir, chunk.relativePath)
+
+ private fun sha256(file: File): String {
+ val digest = MessageDigest.getInstance("SHA-256")
+ file.inputStream().buffered().use { input ->
+ val buffer = ByteArray(64 * 1024)
+ while (true) {
+ val count = input.read(buffer)
+ if (count < 0) break
+ digest.update(buffer, 0, count)
+ }
+ }
+ return digest.digest().joinToString("") { "%02x".format(it) }
+ }
+
+ companion object {
+ const val FORMAT_VERSION = 1
+ const val SAMPLE_RATE = 100
+ const val MAX_EXPORT_SECONDS = 7 * 24 * 60 * 60L
+ }
+}
diff --git a/android/app/src/main/java/com/noop/ui/GroundTruthCollectorScreen.kt b/android/app/src/main/java/com/noop/ui/GroundTruthCollectorScreen.kt
index fa113c7a10..616e4cdd36 100644
--- a/android/app/src/main/java/com/noop/ui/GroundTruthCollectorScreen.kt
+++ b/android/app/src/main/java/com/noop/ui/GroundTruthCollectorScreen.kt
@@ -1,6 +1,9 @@
package com.noop.ui
import android.os.Build
+import android.app.DatePickerDialog
+import android.app.TimePickerDialog
+import android.content.Context
import android.view.HapticFeedbackConstants
import android.view.InputDevice
import android.view.KeyEvent
@@ -234,6 +237,17 @@ fun GroundTruthCollectorScreen(vm: AppViewModel) {
},
)
}
+ NoopButton(
+ text = stringResource(R.string.ground_truth_add_historical),
+ kind = NoopButtonKind.Secondary,
+ fullWidth = true,
+ enabled = !state.active && vm.activeStrapId.isNotBlank(),
+ onClick = {
+ val end = System.currentTimeMillis()
+ collector.createHistoricalSession(vm.activeStrapId, end - 60 * 60 * 1_000L, end)
+ sessions = collector.sessions()
+ },
+ )
Text(stringResource(R.string.ground_truth_sessions), style = NoopType.title2, color = Palette.textPrimary)
if (sessions.isEmpty()) {
Text(stringResource(R.string.ground_truth_no_sessions), style = NoopType.body, color = Palette.textSecondary)
@@ -271,6 +285,20 @@ fun GroundTruthCollectorScreen(vm: AppViewModel) {
}
},
onDelete = { sessionPendingDelete = session },
+ onEditStart = {
+ pickDateTime(context, session.startedAtMs, EARLIEST_EXPORT_MS,
+ session.endedAtMs ?: session.capturedEndedAtMs ?: session.startedAtMs) { value ->
+ collector.setSessionRange(session.id, value, requireNotNull(session.endedAtMs))
+ sessions = collector.sessions()
+ }
+ },
+ onEditEnd = {
+ pickDateTime(context, requireNotNull(session.endedAtMs), session.startedAtMs,
+ System.currentTimeMillis()) { value ->
+ collector.setSessionRange(session.id, session.startedAtMs, value)
+ sessions = collector.sessions()
+ }
+ },
)
}
}
@@ -332,6 +360,8 @@ private fun SessionCard(
onComment: (String) -> Unit,
onExport: () -> Unit,
onDelete: () -> Unit,
+ onEditStart: () -> Unit,
+ onEditEnd: () -> Unit,
) {
val time = remember(session.startedAtMs, session.endedAtMs) { sessionTimeRange(session) }
NoopCard {
@@ -349,6 +379,22 @@ private fun SessionCard(
color = if (captureReady) Palette.statusPositive else Palette.statusCritical,
)
Text(time, style = NoopType.headline, color = Palette.textPrimary)
+ if (!session.active) {
+ Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
+ NoopButton(
+ text = stringResource(R.string.ground_truth_edit_start),
+ kind = NoopButtonKind.Secondary,
+ modifier = Modifier.weight(1f),
+ onClick = onEditStart,
+ )
+ NoopButton(
+ text = stringResource(R.string.ground_truth_edit_end),
+ kind = NoopButtonKind.Secondary,
+ modifier = Modifier.weight(1f),
+ onClick = onEditEnd,
+ )
+ }
+ }
Text(
stringResource(
R.string.ground_truth_session_summary,
@@ -400,6 +446,30 @@ private fun sessionTimeRange(session: GroundTruthCollector.SessionSummary): Stri
}
}
+private fun pickDateTime(
+ context: Context,
+ initialMs: Long,
+ minimumMs: Long,
+ maximumMs: Long,
+ onPicked: (Long) -> Unit,
+) {
+ val initial = java.util.Calendar.getInstance().apply { timeInMillis = initialMs }
+ val dateDialog = DatePickerDialog(context, { _, year, month, day ->
+ TimePickerDialog(context, { _, hour, minute ->
+ val picked = java.util.Calendar.getInstance().apply {
+ set(year, month, day, hour, minute, 0)
+ set(java.util.Calendar.MILLISECOND, 0)
+ }.timeInMillis.coerceIn(minimumMs, maximumMs)
+ onPicked(picked)
+ }, initial.get(java.util.Calendar.HOUR_OF_DAY), initial.get(java.util.Calendar.MINUTE),
+ android.text.format.DateFormat.is24HourFormat(context)).show()
+ }, initial.get(java.util.Calendar.YEAR), initial.get(java.util.Calendar.MONTH),
+ initial.get(java.util.Calendar.DAY_OF_MONTH))
+ dateDialog.datePicker.minDate = minimumMs
+ dateDialog.datePicker.maxDate = maximumMs
+ dateDialog.show()
+}
+
@Composable
private fun CounterColumn(label: String, value: String, modifier: Modifier = Modifier) {
Column(modifier = modifier.padding(horizontal = 4.dp), horizontalAlignment = Alignment.CenterHorizontally) {
@@ -431,3 +501,5 @@ private fun diagnosticAge(epochMs: Long): String {
else -> "${seconds / 3_600}h ${(seconds % 3_600) / 60}m"
}
}
+
+private const val EARLIEST_EXPORT_MS = 946_684_800_000L
diff --git a/android/app/src/main/res/values-de/strings.xml b/android/app/src/main/res/values-de/strings.xml
index ac271cb7e5..486dca0ae3 100644
--- a/android/app/src/main/res/values-de/strings.xml
+++ b/android/app/src/main/res/values-de/strings.xml
@@ -2481,6 +2481,7 @@
Bei markiertem Klick vibrieren
Optionale Bestätigung; standardmäßig aus, um bei langen Sessions Akku zu sparen.
Rohdaten-Session starten
+ Vergangenes Zeitfenster hinzufügen
Letzten Klick rückgängig
Session beenden
Session exportieren
@@ -2500,5 +2501,7 @@
Alle %1$d aufgezeichneten Sessions und ihre Rohdaten werden dauerhaft gelöscht.
Löschen
Abbrechen
+ Start ändern
+ Ende ändern
Ältere Session: Ereignisse können exportiert werden, der zugehörige Strap für Sensordaten ist aber nicht mehr eindeutig.
diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml
index 6e78c0e0b1..356287064c 100644
--- a/android/app/src/main/res/values/strings.xml
+++ b/android/app/src/main/res/values/strings.xml
@@ -2493,6 +2493,7 @@
Vibrate on a counted press
Optional confirmation; disabled by default to save battery during long sessions.
Start raw-data session
+ Add past time window
Undo last click
Stop session
Export session
@@ -2512,5 +2513,7 @@
All %1$d recorded sessions and their captured raw data will be deleted permanently.
Delete
Cancel
+ Edit start
+ Edit end
Legacy session: event export is available, but its strap cannot be identified for sensor export.
diff --git a/android/app/src/test/java/com/noop/testcentre/RawDataCollectorParityTest.kt b/android/app/src/test/java/com/noop/testcentre/RawDataCollectorParityTest.kt
new file mode 100644
index 0000000000..b5449ef323
--- /dev/null
+++ b/android/app/src/test/java/com/noop/testcentre/RawDataCollectorParityTest.kt
@@ -0,0 +1,49 @@
+package com.noop.testcentre
+
+import org.json.JSONObject
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import java.io.File
+
+/** Android half of the shared feature-level raw-data collector parity guard. */
+class RawDataCollectorParityTest {
+ private val oracleBytes: ByteArray
+ get() = requireNotNull(javaClass.classLoader!!.getResourceAsStream("raw_data_collector_parity.json"))
+ .use { it.readBytes() }
+
+ private fun repoRoot(): File {
+ var cursor = File(System.getProperty("user.dir") ?: ".").canonicalFile
+ repeat(4) {
+ if (File(cursor, "android/app/src/main").isDirectory) return cursor
+ cursor = cursor.parentFile ?: cursor
+ }
+ error("repository root not found from ${System.getProperty("user.dir")}")
+ }
+
+ @Test fun androidSurfaceStillImplementsEveryDeclaredCapability() {
+ val root = repoRoot()
+ val source = listOf(
+ "android/app/src/main/java/com/noop/testcentre/GroundTruthCollector.kt",
+ "android/app/src/main/java/com/noop/testcentre/ImuChunkStore.kt",
+ "android/app/src/main/java/com/noop/data/Entities.kt",
+ "android/app/src/main/java/com/noop/data/WhoopRepository.kt",
+ "android/app/src/main/java/com/noop/ui/GroundTruthCollectorScreen.kt",
+ "android/app/src/main/java/com/noop/ble/WhoopBleClient.kt",
+ "android/app/src/main/res/values/strings.xml",
+ ).joinToString("\n") { File(root, it).readText() }
+ val capabilities = JSONObject(oracleBytes.toString(Charsets.UTF_8)).getJSONObject("capabilities")
+ for (name in capabilities.keys()) {
+ val markers = capabilities.getJSONObject(name).getJSONArray("kotlin")
+ for (index in 0 until markers.length()) {
+ val marker = markers.getString(index)
+ assertTrue("Android collector lost $name marker: $marker", source.contains(marker))
+ }
+ }
+ }
+
+ @Test fun androidAndAppleOracleCopiesAreByteIdentical() {
+ val apple = File(repoRoot(), "StrandTests/Resources/raw_data_collector_parity.json")
+ assertEquals(oracleBytes.toList(), apple.readBytes().toList())
+ }
+}
diff --git a/android/app/src/test/resources/raw_data_collector_parity.json b/android/app/src/test/resources/raw_data_collector_parity.json
new file mode 100644
index 0000000000..3129d3d7e9
--- /dev/null
+++ b/android/app/src/test/resources/raw_data_collector_parity.json
@@ -0,0 +1,73 @@
+{
+ "schema_version": 1,
+ "capabilities": {
+ "start_stop": {
+ "swift": ["startGroundTruthRawCapture", "stopGroundTruthRawCapture"],
+ "kotlin": ["startGroundTruthImuCapture", "stopGroundTruthImuCapture"]
+ },
+ "persisted_sessions": {
+ "swift": ["RawDataSessionStore", "persist(session)"],
+ "kotlin": ["GroundTruthCollector", "sessionStartKey"]
+ },
+ "manual_step_and_stair_labels": {
+ "swift": ["store.record(.step)", "store.record(.stair)"],
+ "kotlin": ["KIND_STEP", "KIND_STAIR"]
+ },
+ "undo_last_label": {
+ "swift": ["func undo(", "Undo last click"],
+ "kotlin": ["fun undo(", "ground_truth_undo"]
+ },
+ "exclude_recent_period": {
+ "swift": ["excludeLast(minutes:", "Ignore recent period"],
+ "kotlin": ["excludeLastMinutes(", "ground_truth_exclude_recent"]
+ },
+ "session_comments": {
+ "swift": ["setComment(", "Session comment"],
+ "kotlin": ["setComment(", "ground_truth_comment"]
+ },
+ "from_to_time": {
+ "swift": ["func range(", "endedAtMs"],
+ "kotlin": ["sessionTimeRange(", "endedAtMs"]
+ },
+ "session_export": {
+ "swift": ["exportEntries(for:", "Export session"],
+ "kotlin": ["suspend fun export(", "ground_truth_export"]
+ },
+ "historical_time_windows": {
+ "swift": ["createHistorical(deviceId:", "Historical export window"],
+ "kotlin": ["createHistoricalSession(", "ground_truth_add_historical"]
+ },
+ "editable_analysis_range": {
+ "swift": ["func setRange(sessionId:", "DatePicker(\"From\""],
+ "kotlin": ["fun setSessionRange(", "ground_truth_edit_start"]
+ },
+ "bounded_36h_imu_cache": {
+ "swift": ["rawImuRetentionRows = 36 * 60 * 60"],
+ "kotlin": ["RAW_IMU_RETENTION_ROWS = 36 * 60 * 60"]
+ },
+ "durable_imu_chunk_catalog": {
+ "swift": ["ImuChunkArchiveStore", "ImuChunkMeta"],
+ "kotlin": ["ImuChunkStore", "ImuChunkEntity"]
+ },
+ "explicit_imu_coverage": {
+ "swift": ["imu-coverage.json", "private static func covers"],
+ "kotlin": ["imu_100hz_complete", "private fun covers"]
+ },
+ "delete_one_and_all_with_confirmation": {
+ "swift": ["Delete this session?", "Delete all sessions?", "deleteGroundTruthRawBatches"],
+ "kotlin": ["ground_truth_delete_session_title", "ground_truth_delete_all_title", "deleteAllSessions"]
+ },
+ "active_session_delete_guard": {
+ "swift": ["active != true", ".disabled(store.active != nil)"],
+ "kotlin": ["current.active && current.sessionId == sessionId", "sessions.none { it.active }"]
+ },
+ "bluetooth_reconnect_rearm": {
+ "swift": ["onChangeCompat(of: live.bonded)", "rawCaptureInFlight = false"],
+ "kotlin": ["LaunchedEffect(state.active, state.sessionId, live.connected)", "startGroundTruthImuCapture"]
+ },
+ "raw_data_removed_with_session": {
+ "swift": ["deleteRawBatches(deviceId:", "capturedAt >= ? AND capturedAt <= ?"],
+ "kotlin": ["realtime-imu-$sessionId.bin", "noop-ground-truth-$sessionId.zip"]
+ }
+ }
+}
diff --git a/android/app/src/test/resources/schema_oracle.json b/android/app/src/test/resources/schema_oracle.json
index 4a19cda30f..bef71e5b5b 100644
--- a/android/app/src/test/resources/schema_oracle.json
+++ b/android/app/src/test/resources/schema_oracle.json
@@ -1,6 +1,6 @@
{
"_readme": "SHARED Room<->GRDB SCHEMA ORACLE (#775). Two byte-identical copies: Packages/WhoopStore/Tests/WhoopStoreTests/Resources/schema_oracle.json and android/app/src/test/resources/schema_oracle.json. SchemaOracleTests.swift compares GRDB's PRAGMA table_info/index_list against it; SchemaOracleTest.kt compares Room's exported schema JSON against it. `columns` is the iOS/GRDB shape in GRDB column order (macOS is the reference implementation); every way Android differs is spelled out in an `android` / `iosAbsent` / `androidColumnOrder` override naming a key in `divergenceReasons`. Adding a column, reordering one, or changing a type/nullability on one platform only fails both suites until it is either fixed or written down here.",
- "roomVersion": 33,
+ "roomVersion": 34,
"grdbMigrations": [
"v1",
"v2",
@@ -40,7 +40,8 @@
"v36-whoop-caps-no-spo2",
"v37-daily-avg-sdnn",
"v38-apple-step-hour",
- "v39-ppg-burst-index"
+ "v39-ppg-burst-index",
+ "v40-imu-chunk-catalog"
],
"divergenceReasons": {
"android-orphan-synced-column": "REAL COLUMN DRIFT: `synced` exists on Android only. GRDB's v10 note is explicit that stepSample gets no `synced` column ('unused; see StreamStore'), and v12's ppgHrSample never had one either; the Room entities copied the flag from the older per-second entities. Nothing reads it on Android \u2014 the per-row upload flag belongs to an upload path that does not exist there \u2014 so it is dead width, not divergent data. Removing it needs a Room table rebuild.",
@@ -1252,6 +1253,29 @@
],
"indices": []
},
+ "imuChunk": {
+ "platform": "both",
+ "columns": [
+ { "name": "id", "affinity": "TEXT", "notNull": false, "default": null,
+ "android": { "notNull": true, "divergence": "sqlite-text-pk-nullable" } },
+ { "name": "deviceId", "affinity": "TEXT", "notNull": true, "default": null },
+ { "name": "startTs", "affinity": "INTEGER", "notNull": true, "default": null },
+ { "name": "endTs", "affinity": "INTEGER", "notNull": true, "default": null },
+ { "name": "sampleCount", "affinity": "INTEGER", "notNull": true, "default": null },
+ { "name": "sampleRate", "affinity": "INTEGER", "notNull": true, "default": null },
+ { "name": "formatVersion", "affinity": "INTEGER", "notNull": true, "default": null },
+ { "name": "codec", "affinity": "TEXT", "notNull": true, "default": null },
+ { "name": "relativePath", "affinity": "TEXT", "notNull": true, "default": null },
+ { "name": "byteSize", "affinity": "INTEGER", "notNull": true, "default": null },
+ { "name": "sha256", "affinity": "TEXT", "notNull": true, "default": null },
+ { "name": "createdAt", "affinity": "INTEGER", "notNull": true, "default": null },
+ { "name": "pinnedUntil", "affinity": "INTEGER", "notNull": false, "default": null }
+ ],
+ "primaryKey": ["id"],
+ "indices": [
+ { "name": "index_imuChunk_deviceId_startTs_endTs", "columns": ["deviceId", "startTs", "endTs"], "unique": false }
+ ]
+ },
"rawImuSample": {
"platform": "both",
"columns": [
From 3ecaeecd9d915ae4feceefd5e1874f4cf42af9aa Mon Sep 17 00:00:00 2001
From: Bernd Helm
Date: Wed, 26 Aug 2026 21:24:35 +0200
Subject: [PATCH 17/45] Disambiguate Apple history export formatting
---
Strand/Collect/Collector.swift | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/Strand/Collect/Collector.swift b/Strand/Collect/Collector.swift
index 0fee2e2128..49f101c4e1 100644
--- a/Strand/Collect/Collector.swift
+++ b/Strand/Collect/Collector.swift
@@ -122,12 +122,12 @@ final class Collector {
async let gravity = try? store.gravitySamples(deviceId: deviceId, from: from, to: to, limit: limit)
var lines = ["stream,unix_s,v1,v2,v3,v4"]
lines += await (hr ?? []).map { "heart_rate,\($0.ts),\($0.bpm),,," }
- lines += await (battery ?? []).map { "battery,\($0.ts),\($0.soc.map(String.init) ?? ""),\($0.mv.map(String.init) ?? ""),," }
+ lines += await (battery ?? []).map { "battery,\($0.ts),\($0.soc.map { String($0) } ?? ""),\($0.mv.map { String($0) } ?? ""),," }
lines += await (spo2 ?? []).map { "spo2_raw,\($0.ts),\($0.red),\($0.ir),," }
- lines += await (temp ?? []).map { "skin_temp_raw,\($0.ts),\($0.raw),\($0.aux1Raw.map(String.init) ?? ""),\($0.aux2Raw.map(String.init) ?? "")," }
- lines += await (steps ?? []).map { "steps,\($0.ts),\($0.counter),\($0.activityClass.map(String.init) ?? ""),," }
+ lines += await (temp ?? []).map { "skin_temp_raw,\($0.ts),\($0.raw),\($0.aux1Raw.map { String($0) } ?? ""),\($0.aux2Raw.map { String($0) } ?? "")," }
+ lines += await (steps ?? []).map { "steps,\($0.ts),\($0.counter),\($0.activityClass.map { String($0) } ?? ""),," }
lines += await (resp ?? []).map { "resp_raw,\($0.ts),\($0.raw),,," }
- lines += await (gravity ?? []).map { "gravity,\($0.ts),\($0.x),\($0.y),\($0.z),\($0.dynAccel.map(String.init) ?? "")" }
+ lines += await (gravity ?? []).map { "gravity,\($0.ts),\($0.x),\($0.y),\($0.z),\($0.dynAccel.map { String($0) } ?? "")" }
return Data((lines.joined(separator: "\n") + "\n").utf8)
}
From c8e6c8de5ff326ead02e23d190b3fa2490b37389 Mon Sep 17 00:00:00 2001
From: Bernd Helm
Date: Wed, 26 Aug 2026 21:35:36 +0200
Subject: [PATCH 18/45] Fix Apple parity test error propagation
---
StrandTests/RawDataCollectorParityTests.swift | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/StrandTests/RawDataCollectorParityTests.swift b/StrandTests/RawDataCollectorParityTests.swift
index 848251d072..23fcd7785f 100644
--- a/StrandTests/RawDataCollectorParityTests.swift
+++ b/StrandTests/RawDataCollectorParityTests.swift
@@ -43,7 +43,7 @@ final class RawDataCollectorParityTests: XCTestCase {
func testAndroidAndAppleOracleCopiesAreByteIdentical() throws {
let android = repoRoot.appendingPathComponent("android/app/src/test/resources/raw_data_collector_parity.json")
- XCTAssertEqual(oracleData(), try Data(contentsOf: android),
+ XCTAssertEqual(try oracleData(), try Data(contentsOf: android),
"Raw-data collector parity oracle copies must change together")
}
}
From 8126ccafd7ce3d10ccc71817b1ef87c20d4d9f4b Mon Sep 17 00:00:00 2001
From: Bernd Helm
Date: Wed, 26 Aug 2026 22:43:13 +0200
Subject: [PATCH 19/45] Store session IMU in compressed append-only files
---
.../NoopLocalAccessCore/LocalAccessCore.swift | 2 +-
.../Sources/WhoopStore/Database.swift | 4 +
.../WhoopStore/DeviceRegistryStore.swift | 1 -
.../WhoopStore/Sources/WhoopStore/Reads.swift | 2 +-
.../Sources/WhoopStore/StreamStore.swift | 39 -------
.../Sources/WhoopStore/TimestampHeal.swift | 2 +-
.../DeepCaptureChannelsTests.swift | 1 -
.../Tests/WhoopStoreTests/ReadTests.swift | 9 +-
.../Resources/schema_oracle.json | 28 -----
Strand/BLE/BLEManager.swift | 14 +--
Strand/Collect/Collector.swift | 39 +++----
Strand/Collect/ImuChunkArchiveStore.swift | 50 ++++++++-
Strand/Collect/ImuSessionFileStore.swift | 106 ++++++++++++++++++
Strand/Collect/RawDataSessionStore.swift | 14 +++
Strand/Screens/RawDataCollectorView.swift | 29 +++--
StrandTests/RawDataCollectorParityTests.swift | 2 +
.../Resources/raw_data_collector_parity.json | 12 +-
.../main/java/com/noop/ble/WhoopBleClient.kt | 65 ++++-------
.../main/java/com/noop/data/DeviceRegistry.kt | 2 -
.../java/com/noop/data/DeviceRegistryDao.kt | 2 -
.../src/main/java/com/noop/data/Entities.kt | 36 ------
.../src/main/java/com/noop/data/WhoopDao.kt | 23 ----
.../main/java/com/noop/data/WhoopDatabase.kt | 11 +-
.../java/com/noop/data/WhoopRepository.kt | 28 +----
.../noop/testcentre/GroundTruthCollector.kt | 66 ++++-------
.../java/com/noop/testcentre/ImuChunkStore.kt | 49 ++++++--
.../noop/testcentre/ImuSessionFileStore.kt | 83 ++++++++++++++
.../com/noop/ui/GroundTruthCollectorScreen.kt | 3 +
.../analytics/RegistryDayOwnerSourceTest.kt | 2 -
.../noop/ble/SourceCoordinatorAdoptionTest.kt | 2 -
.../com/noop/data/DeepCaptureMigrationTest.kt | 1 -
.../java/com/noop/data/DeviceRegistryTest.kt | 4 +-
.../java/com/noop/data/RawImuMigrationTest.kt | 46 +-------
.../testcentre/RawDataCollectorParityTest.kt | 2 +
.../resources/raw_data_collector_parity.json | 12 +-
.../app/src/test/resources/schema_oracle.json | 28 -----
36 files changed, 418 insertions(+), 401 deletions(-)
create mode 100644 Strand/Collect/ImuSessionFileStore.swift
create mode 100644 android/app/src/main/java/com/noop/testcentre/ImuSessionFileStore.kt
diff --git a/Packages/NoopLocalAccess/Sources/NoopLocalAccessCore/LocalAccessCore.swift b/Packages/NoopLocalAccess/Sources/NoopLocalAccessCore/LocalAccessCore.swift
index 632e181a28..2e08b9543f 100644
--- a/Packages/NoopLocalAccess/Sources/NoopLocalAccessCore/LocalAccessCore.swift
+++ b/Packages/NoopLocalAccess/Sources/NoopLocalAccessCore/LocalAccessCore.swift
@@ -332,7 +332,7 @@ public final class ReadonlyNoopStore {
let decodedTables = [
"hrSample", "rrInterval", "event", "battery", "spo2Sample",
"skinTempSample", "respSample", "gravitySample", "ppgHrSample", "stepSample",
- "sleepStateSample", "ppgWaveformSample", "rawImuSample", "v18AuxSample",
+ "sleepStateSample", "ppgWaveformSample", "v18AuxSample",
]
var decodedRows = 0
for table in decodedTables where tableNames.contains(table) {
diff --git a/Packages/WhoopStore/Sources/WhoopStore/Database.swift b/Packages/WhoopStore/Sources/WhoopStore/Database.swift
index f2cf80f986..502a445e82 100644
--- a/Packages/WhoopStore/Sources/WhoopStore/Database.swift
+++ b/Packages/WhoopStore/Sources/WhoopStore/Database.swift
@@ -895,6 +895,10 @@ extension WhoopStore {
try db.create(index: "index_imuChunk_deviceId_startTs_endTs", on: "imuChunk",
columns: ["deviceId", "startTs", "endTs"])
}
+ // IMU session payloads are append-only compressed files; SQLite stores only their catalog.
+ migrator.registerMigration("v41-drop-raw-imu-sample") { db in
+ try db.drop(table: "rawImuSample")
+ }
return migrator
}
}
diff --git a/Packages/WhoopStore/Sources/WhoopStore/DeviceRegistryStore.swift b/Packages/WhoopStore/Sources/WhoopStore/DeviceRegistryStore.swift
index 7788dc3642..0793863242 100644
--- a/Packages/WhoopStore/Sources/WhoopStore/DeviceRegistryStore.swift
+++ b/Packages/WhoopStore/Sources/WhoopStore/DeviceRegistryStore.swift
@@ -134,7 +134,6 @@ public struct DeviceRegistryStore: Sendable {
"ppgWaveformSample",
// v28-raw-imu (#423): the opt-in 5/MG raw-IMU offload capture is deviceId-keyed too — "delete all
// of this device's data" must clear it, or the raw inertial samples survive deletion (same defect).
- "rawImuSample",
// v31-deep-capture-channels: the banked 5/MG v18 auxiliary fields are deviceId-keyed per-second
// rows like every stream above, so a "delete all of this device's data" must clear them too.
"v18AuxSample",
diff --git a/Packages/WhoopStore/Sources/WhoopStore/Reads.swift b/Packages/WhoopStore/Sources/WhoopStore/Reads.swift
index 73a3971387..99917aea30 100644
--- a/Packages/WhoopStore/Sources/WhoopStore/Reads.swift
+++ b/Packages/WhoopStore/Sources/WhoopStore/Reads.swift
@@ -397,7 +397,7 @@ extension WhoopStore {
let rawTables = ["hrSample", "rrInterval", "event", "battery",
"spo2Sample", "skinTempSample", "respSample", "gravitySample",
"stepSample", "ppgHrSample", "sleepStateSample", "ppgWaveformSample",
- "rawImuSample", "v18AuxSample"]
+ "v18AuxSample"]
var decoded = 0
for t in rawTables {
decoded += try Int.fetchOne(db, sql: "SELECT COUNT(*) FROM \(t)") ?? 0
diff --git a/Packages/WhoopStore/Sources/WhoopStore/StreamStore.swift b/Packages/WhoopStore/Sources/WhoopStore/StreamStore.swift
index 9c6e2ae4ab..f83a1d50e3 100644
--- a/Packages/WhoopStore/Sources/WhoopStore/StreamStore.swift
+++ b/Packages/WhoopStore/Sources/WhoopStore/StreamStore.swift
@@ -58,10 +58,6 @@ extension WhoopStore {
return out
}
- /// #423 rolling retention for the raw-IMU capture table (twin of Kotlin `RAW_IMU_RETENTION_ROWS`):
- /// 36 h at 1 row/strap-second (~155 MB uncompressed): retrospective activity/workout window.
- public static let rawImuRetentionRows = 36 * 60 * 60
-
/// v31 rolling retention for the v18 aux-slot table (twin of Kotlin `V18_AUX_RETENTION_ROWS`).
///
/// `rawImuSample` is the closest precedent — raw instrumentation banked as a blob, capped rather than
@@ -102,41 +98,6 @@ extension WhoopStore {
}
}
- /// #423: persist decoded 5/MG raw-IMU offload buffers (one row per strap-second, packed i16 BLOB),
- /// then bound the table to the newest `retentionRows` for the device (rolling retention). Written from
- /// the deep-buffer capture seam, not the normal stream path, so it inserts directly (idempotent by ts).
- /// Twin of Kotlin `WhoopRepository.insertRawImu`.
- public func insertRawImu(deviceId: String, rows: [(ts: Int, cols: [Int16])], retentionRows: Int) async throws {
- guard !rows.isEmpty else { return }
- try syncWrite { db in
- let ins = try db.cachedStatement(sql: """
- INSERT INTO rawImuSample (deviceId, ts, samples) VALUES (?, ?, ?)
- ON CONFLICT(deviceId, ts) DO NOTHING
- """)
- // Pack the raw i16 columns to the LE BLOB HERE (packImuColumns is module-internal), so the
- // caller passes plain [Int16] and never needs the packer. Mirrors how `insert` packs ppgWaveform.
- for r in rows { try ins.execute(arguments: [deviceId, r.ts, WhoopStore.packImuColumns(r.cols)]) }
- try db.execute(sql: """
- DELETE FROM rawImuSample WHERE deviceId = ? AND ts < (
- SELECT MIN(ts) FROM (SELECT ts FROM rawImuSample WHERE deviceId = ? ORDER BY ts DESC LIMIT ?))
- """, arguments: [deviceId, deviceId, retentionRows])
- }
- }
-
- public func rawImuSamples(deviceId: String, from: Int, to: Int, limit: Int) async throws
- -> [(ts: Int, cols: [Int16])] {
- try syncRead { db in
- try Row.fetchAll(db, sql: """
- SELECT ts, samples FROM rawImuSample
- WHERE deviceId = ? AND ts >= ? AND ts <= ? ORDER BY ts LIMIT ?
- """, arguments: [deviceId, from, to, limit]).map { row in
- let ts: Int = row["ts"]
- let data: Data = row["samples"]
- return (ts, WhoopStore.unpackImuColumns(data))
- }
- }
- }
-
/// Idempotent upsert of decoded streams by natural key. Returns the number of rows
/// ACTUALLY inserted per stream (0 for rows that already existed).
///
diff --git a/Packages/WhoopStore/Sources/WhoopStore/TimestampHeal.swift b/Packages/WhoopStore/Sources/WhoopStore/TimestampHeal.swift
index 8093834237..128e9c5401 100644
--- a/Packages/WhoopStore/Sources/WhoopStore/TimestampHeal.swift
+++ b/Packages/WhoopStore/Sources/WhoopStore/TimestampHeal.swift
@@ -55,7 +55,7 @@ extension WhoopStore {
let rawTables = ["hrSample", "rrInterval", "event", "battery",
"spo2Sample", "skinTempSample", "respSample",
"gravitySample", "stepSample", "ppgHrSample",
- "sleepStateSample", "ppgWaveformSample", "rawImuSample", "v18AuxSample"]
+ "sleepStateSample", "ppgWaveformSample", "v18AuxSample"]
var rawDeleted = 0
for table in rawTables {
try db.execute(sql: "DELETE FROM \(table) WHERE ts < ? OR ts > ?",
diff --git a/Packages/WhoopStore/Tests/WhoopStoreTests/DeepCaptureChannelsTests.swift b/Packages/WhoopStore/Tests/WhoopStoreTests/DeepCaptureChannelsTests.swift
index 2e5d4a9c6b..4d7f0794b6 100644
--- a/Packages/WhoopStore/Tests/WhoopStoreTests/DeepCaptureChannelsTests.swift
+++ b/Packages/WhoopStore/Tests/WhoopStoreTests/DeepCaptureChannelsTests.swift
@@ -347,7 +347,6 @@ final class DeepCaptureChannelsTests: XCTestCase {
/// The shipped cap is a real bound, and documented in row terms.
func testShippedRetentionConstant() {
XCTAssertEqual(WhoopStore.v18AuxRetentionRows, 604_800) // 7 x 86_400 strap-seconds
- XCTAssertGreaterThan(WhoopStore.v18AuxRetentionRows, WhoopStore.rawImuRetentionRows)
}
/// Deleting a device's data must clear the aux rows too — the same privacy rule every other
diff --git a/Packages/WhoopStore/Tests/WhoopStoreTests/ReadTests.swift b/Packages/WhoopStore/Tests/WhoopStoreTests/ReadTests.swift
index 5737a694cd..2096439301 100644
--- a/Packages/WhoopStore/Tests/WhoopStoreTests/ReadTests.swift
+++ b/Packages/WhoopStore/Tests/WhoopStoreTests/ReadTests.swift
@@ -275,9 +275,6 @@ final class ReadTests: XCTestCase {
ppgWaveform: [PpgWaveformSample(ts: 400, samples: [1, 2, 3])],
v18Aux: [V18AuxSample(ts: 400, slotValues: [1, 2])]),
deviceId: "dev1")
- _ = try await store.insertRawImu(deviceId: "dev1",
- rows: [(ts: 400, cols: [Int16(1), Int16(2)])],
- retentionRows: 1000)
try await store.enqueueRawBatch(
RawBatchMeta(batchId: "b1", deviceId: "dev1",
clockRef: ClockRef(device: 0, wall: 0), capturedAt: 1,
@@ -285,9 +282,9 @@ final class ReadTests: XCTestCase {
frames: [[0xAA, 0x00, 0x01, 0x02]])
let stats = try await store.storageStats()
// dev1: 3 hr + 2 rr + 1 event + 1 battery + 1 spo2 + 1 skinTemp + 1 resp + 1 gravity
- // + 1 step + 1 sleepState + 1 ppgHr + 1 ppgWaveform + 1 v18Aux + 1 rawImu = 17
- // other: 1 hr = 1 → 18 decoded rows across all 14 raw tables.
- XCTAssertEqual(stats.decodedRows, 18)
+ // + 1 step + 1 sleepState + 1 ppgHr + 1 ppgWaveform + 1 v18Aux = 16
+ // other: 1 hr = 1 → 17 decoded rows.
+ XCTAssertEqual(stats.decodedRows, 17)
XCTAssertEqual(stats.rawBatches, 1)
XCTAssertEqual(stats.rawBytes, 4)
}
diff --git a/Packages/WhoopStore/Tests/WhoopStoreTests/Resources/schema_oracle.json b/Packages/WhoopStore/Tests/WhoopStoreTests/Resources/schema_oracle.json
index bef71e5b5b..02b401e3e4 100644
--- a/Packages/WhoopStore/Tests/WhoopStoreTests/Resources/schema_oracle.json
+++ b/Packages/WhoopStore/Tests/WhoopStoreTests/Resources/schema_oracle.json
@@ -1276,34 +1276,6 @@
{ "name": "index_imuChunk_deviceId_startTs_endTs", "columns": ["deviceId", "startTs", "endTs"], "unique": false }
]
},
- "rawImuSample": {
- "platform": "both",
- "columns": [
- {
- "name": "deviceId",
- "affinity": "TEXT",
- "notNull": true,
- "default": null
- },
- {
- "name": "ts",
- "affinity": "INTEGER",
- "notNull": true,
- "default": null
- },
- {
- "name": "samples",
- "affinity": "BLOB",
- "notNull": true,
- "default": null
- }
- ],
- "primaryKey": [
- "deviceId",
- "ts"
- ],
- "indices": []
- },
"respSample": {
"platform": "both",
"columns": [
diff --git a/Strand/BLE/BLEManager.swift b/Strand/BLE/BLEManager.swift
index 232a334b09..988317a57a 100644
--- a/Strand/BLE/BLEManager.swift
+++ b/Strand/BLE/BLEManager.swift
@@ -1786,10 +1786,10 @@ public final class BLEManager: NSObject, ObservableObject {
/// Start a manually stopped 5/MG raw-data session. Returns false when another capture is active.
@discardableResult
- public func startGroundTruthRawCapture() -> Bool {
+ public func startGroundTruthRawCapture(sessionId: String) -> Bool {
guard !rawCaptureInFlight else { return false }
rawCaptureInFlight = true
- collector?.beginRawCapture(seconds: RawCaptureWindow.maxSeconds)
+ collector?.beginGroundTruthRawCapture(sessionId: sessionId)
send(.startRawData, payload: [0x01], writeType: .withResponse)
send(.toggleIMUMode,
payload: selectedModel.deviceFamily == .whoop5 ? [0x01, 0x01] : [0x01],
@@ -1811,6 +1811,10 @@ public final class BLEManager: NSObject, ObservableObject {
log("Raw-data session: stopped + flushed")
}
+ public func finishGroundTruthRawCapture(sessionId: String) {
+ collector?.finishGroundTruthRawCapture(sessionId: sessionId)
+ }
+
public func groundTruthRawBatches(from: Int, to: Int) async -> [(RawBatchMeta, [[UInt8]])] {
await collector?.rawBatches(from: from, to: to) ?? []
}
@@ -1825,10 +1829,6 @@ public final class BLEManager: NSObject, ObservableObject {
await collector?.deleteRawBatches(from: from, to: to) ?? 0
}
- public func groundTruthRawImuSamples(from: Int, to: Int) async -> [(ts: Int, cols: [Int16])] {
- await collector?.rawImuSamples(from: from, to: to) ?? []
- }
-
public func groundTruthImuChunks(from: Int, to: Int) async -> [ImuChunkMeta] {
await collector?.imuChunks(from: from, to: to) ?? []
}
@@ -6016,8 +6016,6 @@ extension BLEManager: @preconcurrency CBPeripheralDelegate {
// BEFORE the offload branch so it catches the burst; no-op unless capture is on.
puffinDeepBufferLog.appendIfDeepBuffer(frame: frame, char: characteristic.uuid, isOffload: isOffload)
// #423: the queryable twin of that diagnostics line — persist the decoded 100 Hz 6-axis
- // IMU samples into the rawImuSample table when raw capture is on (same gate, gated inside).
- collector?.storeRawImu(frame: frame)
if isOffload {
// Same policy as WHOOP4: historical offload frames are bulk sync traffic.
// Keep them out of the live UI parser during backfill and let Backfiller
diff --git a/Strand/Collect/Collector.swift b/Strand/Collect/Collector.swift
index 49f101c4e1..cdce01318d 100644
--- a/Strand/Collect/Collector.swift
+++ b/Strand/Collect/Collector.swift
@@ -12,12 +12,6 @@ protocol StoreWriting: AnyObject {
-> (hr: Int, rr: Int, events: Int, battery: Int,
spo2: Int, skinTemp: Int, resp: Int, gravity: Int)
func enqueueRawBatch(_ meta: RawBatchMeta, frames: [[UInt8]]) async throws
- func insertRawImu(deviceId: String, rows: [(ts: Int, cols: [Int16])], retentionRows: Int) async throws
-}
-extension StoreWriting {
- /// #423: default no-op so a test SpyStore needn't implement the raw-IMU capture path. WhoopStore's
- /// real impl (StreamStore.swift) satisfies the requirement and is used in production.
- func insertRawImu(deviceId: String, rows: [(ts: Int, cols: [Int16])], retentionRows: Int) async throws {}
}
extension WhoopStore: StoreWriting {}
@@ -69,6 +63,7 @@ final class Collector {
/// activity sample" action can persist raw even when `enableRawCapture` is off. The window's
/// monotonic deadline auto-expires so a missed stop callback can't leak raw forever.
private var rawCapture = RawCaptureWindow()
+ private var groundTruthSessionId: String?
/// #47: buffer the (raw frame, pre-parsed) pair. The raw bytes are still needed for the raw-capture
/// outbox; the parse is the one the BLE seam already did, so `flush` doesn't re-decode the batch.
private var buffer: [(frame: [UInt8], parsed: ParsedFrame)] = []
@@ -137,12 +132,6 @@ final class Collector {
return (try? await store.deleteRawBatches(deviceId: deviceId, from: from, to: to)) ?? 0
}
- func rawImuSamples(from: Int, to: Int) async -> [(ts: Int, cols: [Int16])] {
- guard let store = concreteStore else { return [] }
- let limit = min(max(to - from + 1, 1), 7 * 24 * 60 * 60)
- return (try? await store.rawImuSamples(deviceId: deviceId, from: from, to: to, limit: limit)) ?? []
- }
-
func imuChunks(from: Int, to: Int) async -> [ImuChunkMeta] {
guard let store = concreteStore else { return [] }
return (try? await store.imuChunks(deviceId: deviceId, from: from, to: to)) ?? []
@@ -188,16 +177,6 @@ final class Collector {
/// 1244-B 6-axis buffer decodes (rawColumns nil otherwise). Fire-and-forget into the store, bounded by
/// a rolling retention prune. Raw i16, no downstream consumer yet. Twin of Android
/// `WhoopBleClient.storeWhoop5RawImuIfBuffer`.
- func storeRawImu(frame: [UInt8]) {
- guard UserDefaults.standard.bool(forKey: PuffinFrameRecorder.enabledKey) else { return }
- guard let cols = Whoop5RawImu.rawColumns(frame), let baseTs = Whoop5RawImu.baseTs(frame) else { return }
- let dev = deviceId
- Task { [store] in
- try? await store.insertRawImu(
- deviceId: dev, rows: [(ts: baseTs, cols: cols)], retentionRows: WhoopStore.rawImuRetentionRows)
- }
- }
-
/// Buffer one complete frame + its pre-parsed decode (synchronous: preserves delegate arrival order).
/// Auto-flushes via a detached Task when the cadence threshold is hit (flush is async). (#47)
func ingest(frame: [UInt8], parsed: ParsedFrame) {
@@ -205,6 +184,7 @@ final class Collector {
assert(parsed == parseFrame(frame, family: family),
"Collector.ingest: threaded ParsedFrame != fresh parse (#47 parse-once invariant)")
#endif
+ recordGroundTruthImu(frame)
buffer.append((frame, parsed))
// Pre-clock only: bound memory if GET_CLOCK never lands while data keeps flowing.
// Drop OLDEST beyond the cap (keep most recent). Post-clock this branch is skipped —
@@ -288,6 +268,21 @@ final class Collector {
rawCapture.open(at: monotonic(), duration: seconds)
}
+ func beginGroundTruthRawCapture(sessionId: String) {
+ rawCapture.open(at: monotonic(), duration: RawCaptureWindow.maxSeconds)
+ groundTruthSessionId = sessionId
+ }
+
+ func finishGroundTruthRawCapture(sessionId: String) {
+ guard groundTruthSessionId == sessionId else { return }
+ groundTruthSessionId = nil
+ }
+
+ private func recordGroundTruthImu(_ frame: [UInt8]) {
+ _ = ImuSessionFileStore.shared.append(deviceId: deviceId, frame: frame,
+ receivedAtMs: Int64(Date().timeIntervalSince1970 * 1_000))
+ }
+
/// Flush WHILE the window is still active so the just-captured frames get persisted as raw,
/// THEN close the window.
func endRawCapture() async {
diff --git a/Strand/Collect/ImuChunkArchiveStore.swift b/Strand/Collect/ImuChunkArchiveStore.swift
index 78e08f15e9..c009cac97d 100644
--- a/Strand/Collect/ImuChunkArchiveStore.swift
+++ b/Strand/Collect/ImuChunkArchiveStore.swift
@@ -1,5 +1,7 @@
import CryptoKit
+import Compression
import Foundation
+import WhoopProtocol
import WhoopStore
/// Apple twin of Android ImuChunkStore. ZIP entries and samples.bin layout are platform-identical.
@@ -17,12 +19,14 @@ final class ImuChunkArchiveStore {
try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
}
- func pin(deviceId: String, from: Int, to: Int, ble: BLEManager) async -> [ImuChunkMeta] {
+ func pin(sessionId: String, deviceId: String, from: Int, to: Int, ble: BLEManager) async -> [ImuChunkMeta] {
let existing = await ble.groundTruthImuChunks(from: from, to: to)
.filter { $0.pinnedUntil == Int.max && file($0).isFileURL && FileManager.default.fileExists(atPath: file($0).path) }
- if existing.contains(where: { $0.startTs <= from && $0.endTs >= to }) { return existing }
- let rows = await ble.groundTruthRawImuSamples(from: from, to: to)
+ if existing.contains(where: { $0.startTs <= from && $0.endTs >= to
+ && $0.sampleCount >= ($0.endTs - $0.startTs + 1) * $0.sampleRate }) { return existing }
+ let rows = readSessionRows(sessionId: sessionId, from: from, to: to)
guard !rows.isEmpty else { return existing }
+ let startTs = rows.map(\.ts).min()!, endTs = rows.map(\.ts).max()!
var samples = Data()
for row in rows {
@@ -38,7 +42,8 @@ final class ImuChunkArchiveStore {
"sample_rate_hz": Self.sampleRate,
"axes": ["ax", "ay", "az", "gx", "gy", "gz"],
"layout": "column-major-int16-le", "row_count": rows.count,
- "start_ts": rows.first!.ts, "end_ts": rows.last!.ts,
+ "ordering": "unspecified", "timestamp_source": "strap",
+ "start_ts": startTs, "end_ts": endTs,
]
guard let manifestData = try? JSONSerialization.data(withJSONObject: manifest, options: [.prettyPrinted, .sortedKeys]),
let staged = FileExport.zipData(entries: [
@@ -51,7 +56,7 @@ final class ImuChunkArchiveStore {
let base = directory.deletingLastPathComponent().deletingLastPathComponent()
let relative = destination.path.replacingOccurrences(of: base.path + "/", with: "")
let chunk = ImuChunkMeta(
- id: id, deviceId: deviceId, startTs: rows.first!.ts, endTs: rows.last!.ts,
+ id: id, deviceId: deviceId, startTs: startTs, endTs: endTs,
sampleCount: rows.count * Self.sampleRate, sampleRate: Self.sampleRate,
formatVersion: Self.formatVersion, codec: "zip-deflate", relativePath: relative,
byteSize: bytes.count, sha256: SHA256.hash(data: bytes).map { String(format: "%02x", $0) }.joined(),
@@ -65,6 +70,41 @@ final class ImuChunkArchiveStore {
let base = directory.deletingLastPathComponent().deletingLastPathComponent()
return base.appendingPathComponent(chunk.relativePath)
}
+
+ private func readSessionRows(sessionId: String, from: Int, to: Int) -> [(ts: Int, cols: [Int16])] {
+ let url = ImuSessionFileStore.shared.file(sessionId)
+ guard let data = try? Data(contentsOf: url) else { return [] }
+ let bytes = [UInt8](data); var offset = 0
+ var rows: [(Int, [Int16])] = []
+ while offset + 24 <= bytes.count {
+ let headerTs = Int64(bigEndianBytes: bytes, at: offset); offset += 16 // strap + receipt timestamps
+ let length = Int(bytes[offset]) << 24 | Int(bytes[offset + 1]) << 16
+ | Int(bytes[offset + 2]) << 8 | Int(bytes[offset + 3])
+ offset += 4
+ let compressedLength = Int(bytes[offset]) << 24 | Int(bytes[offset + 1]) << 16
+ | Int(bytes[offset + 2]) << 8 | Int(bytes[offset + 3]); offset += 4
+ guard length > 0, length <= 1_048_576, compressedLength > 0,
+ compressedLength <= 1_048_576, offset + compressedLength <= bytes.count else { break }
+ let compressed = Data(bytes[offset..= from, ts <= to,
+ let cols = Whoop5RawImu.rawColumns(frame) else { continue }
+ rows.append((ts, cols))
+ }
+ return rows
+ }
+}
+
+private extension Int64 {
+ init(bigEndianBytes bytes: [UInt8], at offset: Int) {
+ self = bytes[offset..<(offset + 8)].reduce(0) { ($0 << 8) | Int64($1) }
+ }
}
private extension Data {
diff --git a/Strand/Collect/ImuSessionFileStore.swift b/Strand/Collect/ImuSessionFileStore.swift
new file mode 100644
index 0000000000..387dad5385
--- /dev/null
+++ b/Strand/Collect/ImuSessionFileStore.swift
@@ -0,0 +1,106 @@
+import Compression
+import Foundation
+import WhoopProtocol
+
+/// Persisted routing table plus append-only, independently compressed IMU session streams.
+@MainActor
+final class ImuSessionFileStore {
+ static let shared = ImuSessionFileStore()
+
+ private struct Window: Codable { let id, deviceId: String; var from, to: Int64? }
+ private let defaults = UserDefaults.standard
+ private let key = "imu-session-windows-v1"
+ private let directory: URL
+ private var seen: [String: Set] = [:]
+
+ private init() {
+ let fm = FileManager.default
+ let base = (try? fm.url(for: .applicationSupportDirectory, in: .userDomainMask,
+ appropriateFor: nil, create: true)) ?? fm.temporaryDirectory
+ directory = base.appendingPathComponent("OpenWhoop/RawDataSessions", isDirectory: true)
+ try? fm.createDirectory(at: directory, withIntermediateDirectories: true)
+ }
+
+ func start(id: String, deviceId: String, fromMs: Int64) {
+ var value = windows().filter { $0.id != id }
+ value.append(Window(id: id, deviceId: deviceId, from: fromMs / 1_000, to: nil)); save(value)
+ }
+
+ func complete(id: String, toMs: Int64) {
+ var value = windows()
+ if let index = value.firstIndex(where: { $0.id == id }) { value[index].to = toMs / 1_000; save(value) }
+ }
+
+ func register(id: String, deviceId: String, fromMs: Int64, toMs: Int64) {
+ var value = windows().filter { $0.id != id }
+ value.append(Window(id: id, deviceId: deviceId, from: fromMs / 1_000, to: toMs / 1_000)); save(value)
+ }
+
+ func remove(id: String) { save(windows().filter { $0.id != id }); seen[id] = nil }
+
+ @discardableResult
+ func append(deviceId: String, frame: [UInt8], receivedAtMs: Int64) -> Int {
+ guard let ts = Whoop5RawImu.baseTs(frame), Whoop5RawImu.rawColumns(frame) != nil else { return 0 }
+ var count = 0
+ for window in windows() where window.deviceId == deviceId && Int64(ts) >= window.from
+ && (window.to == nil || Int64(ts) <= window.to!) {
+ var timestamps = seen[window.id] ?? scan(window.id)
+ guard timestamps.insert(Int64(ts)).inserted,
+ let compressed = Self.deflate(Data(frame)) else { continue }
+ var record = Data()
+ record.appendBigEndian(Int64(ts)); record.appendBigEndian(receivedAtMs)
+ record.appendBigEndian(Int32(frame.count)); record.appendBigEndian(Int32(compressed.count))
+ record.append(compressed)
+ let url = file(window.id)
+ if !FileManager.default.fileExists(atPath: url.path) { FileManager.default.createFile(atPath: url.path, contents: nil) }
+ guard let handle = try? FileHandle(forWritingTo: url) else { continue }
+ do {
+ try handle.seekToEnd()
+ try handle.write(contentsOf: record)
+ try handle.close()
+ timestamps.insert(Int64(ts))
+ seen[window.id] = timestamps
+ count += 1
+ } catch { try? handle.close() }
+ }
+ return count
+ }
+
+ func file(_ id: String) -> URL { directory.appendingPathComponent("realtime-imu-\(id).imus") }
+
+ private func windows() -> [Window] {
+ guard let data = defaults.data(forKey: key) else { return [] }
+ return (try? JSONDecoder().decode([Window].self, from: data)) ?? []
+ }
+ private func save(_ value: [Window]) { defaults.set(try? JSONEncoder().encode(value), forKey: key) }
+
+ private func scan(_ id: String) -> Set {
+ guard let data = try? Data(contentsOf: file(id)) else { return [] }
+ var result = Set(), offset = 0
+ while offset + 24 <= data.count {
+ let ts: Int64 = data.bigEndian(at: offset); let compressed: Int32 = data.bigEndian(at: offset + 20)
+ guard compressed >= 0, offset + 24 + Int(compressed) <= data.count else { break }
+ result.insert(ts); offset += 24 + Int(compressed)
+ }
+ return result
+ }
+
+ private static func deflate(_ input: Data) -> Data? {
+ let capacity = max(input.count + 128, 256); var output = Data(count: capacity)
+ let written = output.withUnsafeMutableBytes { dst in input.withUnsafeBytes { src in
+ compression_encode_buffer(dst.bindMemory(to: UInt8.self).baseAddress!, capacity,
+ src.bindMemory(to: UInt8.self).baseAddress!, input.count,
+ nil, COMPRESSION_ZLIB)
+ }}
+ guard written > 0 else { return nil }; output.count = written; return output
+ }
+}
+
+private extension Data {
+ mutating func appendBigEndian(_ value: T) {
+ var value = value.bigEndian; Swift.withUnsafeBytes(of: &value) { append(contentsOf: $0) }
+ }
+ func bigEndian(at offset: Int) -> T {
+ self[offset..<(offset + MemoryLayout.size)].reduce(T.zero) { ($0 << 8) | T($1) }
+ }
+}
diff --git a/Strand/Collect/RawDataSessionStore.swift b/Strand/Collect/RawDataSessionStore.swift
index d38ffd81b4..c567b54f38 100644
--- a/Strand/Collect/RawDataSessionStore.swift
+++ b/Strand/Collect/RawDataSessionStore.swift
@@ -86,10 +86,12 @@ final class RawDataSessionStore: ObservableObject {
comment: "", exported: false, events: [started])
sessions.insert(session, at: 0)
persist(session)
+ ImuSessionFileStore.shared.start(id: session.id, deviceId: deviceId, fromMs: millis)
return session
}
func stop(now: Date = Date()) {
+ let activeId = active?.id
mutateActive { session in
let millis = Int64(now.timeIntervalSince1970 * 1_000)
session.endedAtMs = millis
@@ -97,6 +99,7 @@ final class RawDataSessionStore: ObservableObject {
session.events.append(Event(atMs: millis, kind: .stop,
stepsTotal: session.steps, stairsTotal: session.stairs))
}
+ if let activeId { ImuSessionFileStore.shared.complete(id: activeId, toMs: Int64(now.timeIntervalSince1970 * 1_000)) }
}
@discardableResult
@@ -112,6 +115,7 @@ final class RawDataSessionStore: ObservableObject {
steps: 0, stairs: 0, excludedWindows: 0, comment: "",
exported: false, events: events)
sessions.insert(session, at: 0); persist(session)
+ ImuSessionFileStore.shared.register(id: id, deviceId: deviceId, fromMs: fromMs, toMs: toMs)
return session
}
@@ -128,6 +132,9 @@ final class RawDataSessionStore: ObservableObject {
excludedWindows: session.excludedWindows, comment: session.comment,
exported: false, events: session.events)
}
+ if let session = sessions.first(where: { $0.id == sessionId }) {
+ ImuSessionFileStore.shared.register(id: sessionId, deviceId: session.deviceId, fromMs: fromMs, toMs: toMs)
+ }
}
func record(_ kind: LabelKind, now: Date = Date()) {
@@ -169,9 +176,16 @@ final class RawDataSessionStore: ObservableObject {
func markExported(_ sessionId: String) { mutate(sessionId) { $0.exported = true } }
+ func finishImuRecovery(_ sessionId: String) {
+ ImuSessionFileStore.shared.remove(id: sessionId)
+ try? FileManager.default.removeItem(at: ImuSessionFileStore.shared.file(sessionId))
+ }
+
func removeMetadata(_ sessionId: String) {
guard sessions.first(where: { $0.id == sessionId })?.active != true else { return }
try? FileManager.default.removeItem(at: file(sessionId))
+ try? FileManager.default.removeItem(at: ImuSessionFileStore.shared.file(sessionId))
+ ImuSessionFileStore.shared.remove(id: sessionId)
sessions.removeAll { $0.id == sessionId }
}
diff --git a/Strand/Screens/RawDataCollectorView.swift b/Strand/Screens/RawDataCollectorView.swift
index 86e36b5c79..f30a10f9ad 100644
--- a/Strand/Screens/RawDataCollectorView.swift
+++ b/Strand/Screens/RawDataCollectorView.swift
@@ -34,11 +34,11 @@ struct RawDataCollectorView: View {
.task {
// Restore a session after navigation/process lifecycle changes. The BLE layer rejects a
// duplicate arm, so this is safe when capture never stopped.
- if store.active != nil, live.bonded { model.ble.startGroundTruthRawCapture() }
+ if let active = store.active, live.bonded { _ = model.ble.startGroundTruthRawCapture(sessionId: active.id) }
await refreshRawCounts()
}
.onChangeCompat(of: live.bonded) { bonded in
- if bonded, store.active != nil { model.ble.startGroundTruthRawCapture() }
+ if bonded, let active = store.active { _ = model.ble.startGroundTruthRawCapture(sessionId: active.id) }
}
.confirmationDialog("Delete this session?", isPresented: Binding(
get: { deleteCandidate != nil }, set: { if !$0 { deleteCandidate = nil } }
@@ -221,9 +221,9 @@ struct RawDataCollectorView: View {
}
private func start() {
- guard model.ble.startGroundTruthRawCapture() else { return }
- if store.start(deviceId: model.ble.deviceId) == nil {
- Task { await model.ble.stopGroundTruthRawCapture() }
+ guard let session = store.start(deviceId: model.ble.deviceId) else { return }
+ guard model.ble.startGroundTruthRawCapture(sessionId: session.id) else {
+ store.stop(); store.removeMetadata(session.id); return
}
}
@@ -237,13 +237,15 @@ struct RawDataCollectorView: View {
guard let end = session.endedAtMs else { return }
exportingId = session.id
let from = Int(session.startedAtMs / 1_000), to = Int(end / 1_000)
- let chunks = await archive.pin(deviceId: session.deviceId, from: from, to: to, ble: model.ble)
+ let chunks = await archive.pin(sessionId: session.id, deviceId: session.deviceId,
+ from: from, to: to, ble: model.ble)
let history = await model.ble.groundTruthHistoryCSV(from: from, to: to)
var entries = store.exportEntries(for: session, raw: [])
entries.append(.init(name: "history-sensors.csv", data: history))
+ let imuComplete = Self.covers(chunks, from: from, to: to)
let coverage: [String: Any] = [
"requested_start_ts": from, "requested_end_ts": to,
- "complete": Self.covers(chunks.map { ($0.startTs, $0.endTs) }, from: from, to: to),
+ "complete": imuComplete,
"chunks": chunks.map { ["start_ts": $0.startTs, "end_ts": $0.endTs,
"sample_count": $0.sampleCount, "sha256": $0.sha256] }
]
@@ -258,12 +260,17 @@ struct RawDataCollectorView: View {
let result = await FileExport.exportBundle(entries: entries,
suggestedName: "noop-ground-truth-\(session.id).zip")
if result == nil { exportError = "The export file could not be created or shared." }
- else { store.markExported(session.id) }
+ else {
+ store.markExported(session.id)
+ if imuComplete { store.finishImuRecovery(session.id) }
+ model.ble.finishGroundTruthRawCapture(sessionId: session.id)
+ }
exportingId = nil
}
private func delete(_ session: RawDataSessionStore.Session) async {
guard session.endedAtMs != nil else { return }
+ model.ble.finishGroundTruthRawCapture(sessionId: session.id)
// Raw chunks may overlap another session; retention cleanup owns physical deletion.
store.removeMetadata(session.id)
rawBatchCounts[session.id] = nil
@@ -292,9 +299,11 @@ struct RawDataCollectorView: View {
return "\(date) · \(time(session.startedAtMs))–\(end)"
}
- private static func covers(_ intervals: [(Int, Int)], from: Int, to: Int) -> Bool {
+ private static func covers(_ chunks: [ImuChunkMeta], from: Int, to: Int) -> Bool {
var cursor = from
- for (start, end) in intervals.sorted(by: { $0.0 < $1.0 }) {
+ for chunk in chunks.sorted(by: { $0.startTs < $1.startTs }) {
+ let start = chunk.startTs, end = chunk.endTs
+ if chunk.sampleCount < (end - start + 1) * chunk.sampleRate { continue }
if start > cursor { return false }
if end >= cursor { cursor = end + 1 }
if cursor > to { return true }
diff --git a/StrandTests/RawDataCollectorParityTests.swift b/StrandTests/RawDataCollectorParityTests.swift
index 23fcd7785f..f8a3a1c42e 100644
--- a/StrandTests/RawDataCollectorParityTests.swift
+++ b/StrandTests/RawDataCollectorParityTests.swift
@@ -28,6 +28,8 @@ final class RawDataCollectorParityTests: XCTestCase {
"Strand/Collect/RawDataSessionStore.swift", "Strand/Collect/Collector.swift",
"Strand/BLE/BLEManager.swift", "Strand/Screens/RawDataCollectorView.swift",
"Strand/Collect/ImuChunkArchiveStore.swift",
+ "Strand/Collect/ImuSessionFileStore.swift",
+ "Packages/WhoopStore/Sources/WhoopStore/Database.swift",
"Packages/WhoopStore/Sources/WhoopStore/ImuChunkCatalog.swift",
"Packages/WhoopStore/Sources/WhoopStore/StreamStore.swift",
"Packages/WhoopStore/Sources/WhoopStore/RawOutbox.swift",
diff --git a/StrandTests/Resources/raw_data_collector_parity.json b/StrandTests/Resources/raw_data_collector_parity.json
index 3129d3d7e9..e3c13ecb28 100644
--- a/StrandTests/Resources/raw_data_collector_parity.json
+++ b/StrandTests/Resources/raw_data_collector_parity.json
@@ -41,9 +41,13 @@
"swift": ["func setRange(sessionId:", "DatePicker(\"From\""],
"kotlin": ["fun setSessionRange(", "ground_truth_edit_start"]
},
- "bounded_36h_imu_cache": {
- "swift": ["rawImuRetentionRows = 36 * 60 * 60"],
- "kotlin": ["RAW_IMU_RETENTION_ROWS = 36 * 60 * 60"]
+ "file_only_imu_storage": {
+ "swift": ["v41-drop-raw-imu-sample"],
+ "kotlin": ["MIGRATION_34_35", "DROP TABLE IF EXISTS `rawImuSample`"]
+ },
+ "session_bound_imu_file": {
+ "swift": ["ImuSessionFileStore", "realtime-imu-\\(id).imus"],
+ "kotlin": ["ImuSessionFileStore", "realtime-imu-$id.imus"]
},
"durable_imu_chunk_catalog": {
"swift": ["ImuChunkArchiveStore", "ImuChunkMeta"],
@@ -67,7 +71,7 @@
},
"raw_data_removed_with_session": {
"swift": ["deleteRawBatches(deviceId:", "capturedAt >= ? AND capturedAt <= ?"],
- "kotlin": ["realtime-imu-$sessionId.bin", "noop-ground-truth-$sessionId.zip"]
+ "kotlin": ["realtime-imu-$sessionId.imus", "noop-ground-truth-$sessionId.zip"]
}
}
}
diff --git a/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt b/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt
index c0f0477a52..0d58f8393c 100644
--- a/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt
+++ b/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt
@@ -28,9 +28,9 @@ import com.noop.NoopApplication
import com.noop.data.HrRow
import com.noop.data.RrRow
import com.noop.data.StreamBatch
-import com.noop.data.RawImuSampleEntity
import com.noop.data.StreamPersistence
import com.noop.protocol.Whoop5RawImu
+import com.noop.testcentre.ImuSessionFileStore
import com.noop.data.WhoopRepository
import com.noop.protocol.AlarmPayload
import com.noop.protocol.DYN_ACCEL_STILL_THRESHOLD_G
@@ -2184,7 +2184,6 @@ class WhoopBleClient(
@Volatile private var groundTruthImuCommandAllowed = false
private var groundTruthImuSessionId: String? = null
- private var groundTruthImuOut: DataOutputStream? = null
/** Start the hardware-confirmed WHOOP 5 realtime IMU mode for an explicit ground-truth session. */
@Synchronized
@@ -2203,15 +2202,7 @@ class WhoopBleClient(
)
return false
}
- if (groundTruthImuSessionId != sessionId || groundTruthImuOut == null) {
- closeGroundTruthImuFile()
- val dir = File(context.filesDir, "ground-truth").apply { mkdirs() }
- val file = File(dir, "realtime-imu-$sessionId.bin")
- // Append lets a foreground collector resume the same session after an Android process restart.
- groundTruthImuOut = DataOutputStream(BufferedOutputStream(FileOutputStream(file, true), 64 * 1024))
- groundTruthImuSessionId = sessionId
- _groundTruthImuStatus.value = GroundTruthImuStatus(sessionId = sessionId, note = "Capture file open")
- }
+ groundTruthImuSessionId = sessionId
groundTruthImuCommandAllowed = true
try {
// The verified bounded raw-capture sequence is START_RAW_DATA followed by the
@@ -2239,23 +2230,24 @@ class WhoopBleClient(
}
}
log("Ground-truth realtime IMU requested OFF (session=${groundTruthImuSessionId ?: "none"})")
- _groundTruthImuStatus.update { it.copy(requested = false, note = "Stopped") }
- closeGroundTruthImuFile()
+ groundTruthImuSessionId = null
+ _groundTruthImuStatus.update { it.copy(requested = false, note = "Stopped; accepting history repair") }
+ }
+
+ @Synchronized
+ fun finishGroundTruthImuCapture(sessionId: String) {
+ if (groundTruthImuSessionId == sessionId) groundTruthImuSessionId = null
}
@Synchronized
private fun recordGroundTruthImuFrame(frame: ByteArray) {
if (frame.size <= 8) return
val packetType = frame[8].toInt() and 0xFF
- // 51 is the named realtime IMU stream. Firmware/protocol revisions also use the older raw
- // stream (43) and historical IMU stream (52); retain all three losslessly during an explicit
- // ground-truth session instead of silently discarding a valid 100 Hz producer.
- if (packetType !in setOf(43, 51, 52)) return
- groundTruthImuOut?.let { out ->
- runCatching {
- out.writeLong(System.currentTimeMillis())
- out.writeInt(frame.size)
- out.write(frame)
+ // Decode, not the transport packet type, is authoritative: history replay can wrap the same
+ // 100 Hz payload differently from realtime delivery.
+ runCatching {
+ val routed = ImuSessionFileStore(context).append(deviceId, frame)
+ if (routed > 0) {
val now = System.currentTimeMillis()
_groundTruthImuStatus.update {
it.copy(
@@ -2265,22 +2257,13 @@ class WhoopBleClient(
note = "Receiving IMU packet type $packetType",
)
}
- }.onFailure {
- log("Ground-truth realtime IMU write failed (${it.javaClass.simpleName}); capture stopped")
- _groundTruthImuStatus.update { status -> status.copy(requested = false, note = "Write failed: ${it.javaClass.simpleName}") }
- closeGroundTruthImuFile()
}
+ }.onFailure {
+ log("Ground-truth IMU append failed (${it.javaClass.simpleName})")
+ _groundTruthImuStatus.update { status -> status.copy(note = "Write failed: ${it.javaClass.simpleName}") }
}
}
- @Synchronized
- private fun closeGroundTruthImuFile() {
- runCatching { groundTruthImuOut?.flush() }
- runCatching { groundTruthImuOut?.close() }
- groundTruthImuOut = null
- groundTruthImuSessionId = null
- }
-
/** Frame reassembler for the fragmented custom notify chars (port of Reassembler). Reassigned per
* connection with the detected family — WHOOP5/MG frames use a different length encoding. */
private var reassembler = Reassembler()
@@ -6245,7 +6228,6 @@ class WhoopBleClient(
// BEFORE the offload branch so it catches the burst; no-op unless capture is on.
writeWhoop5DeepBufferIfBig(uuid.toString(), frame, isOffloadFrame(frame, connectedFamily))
// #423: the queryable twin of that diagnostics line — persist the decoded IMU
- // samples (100 Hz 6-axis) into the rawImuSample table when raw capture is on.
storeWhoop5RawImuIfBuffer(frame)
}
// Opt-in raw capture: record EVERY frame of the session (offload AND live flood —
@@ -9252,10 +9234,7 @@ class WhoopBleClient(
* one previous generation. Cheap for every other frame: a length + single-byte compare BEFORE the
* pref read; no-op unless the capture toggle is on.
*/
- /** #423: persist the WHOOP 5/MG raw-IMU offload buffer NOOP already decodes for the deep-buffer log —
- * the queryable twin of that (table-less) diagnostics line. Same `isCaptureEnabled` gate; only the
- * 1244-B 6-axis buffer decodes (rawColumns null otherwise). IO-dispatched so it never blocks the GATT
- * thread; bounded by a rolling retention prune. Raw i16, no downstream consumer yet (instrument-first). */
+ /** Debug heartbeat for valid WHOOP 5/MG raw-IMU buffers. Session persistence is file-only. */
private fun storeWhoop5RawImuIfBuffer(frame: ByteArray) {
// The bounded collector owns its own capture gate. It must not require the unrelated passive
// protocol-trace preference: live and history copies then converge on the same keyed raw-IMU
@@ -9263,19 +9242,13 @@ class WhoopBleClient(
if (!PuffinExperiment.from(context).isCaptureEnabled && groundTruthImuSessionId == null) return
val cols = Whoop5RawImu.rawColumns(frame) ?: return
val baseTs = PuffinDeepBufferLog.strapTs(frame)?.toLong() ?: return
- val dev = deviceId
// #423 debug heartbeat: confirm the offload IMU is arriving + decoding on-device without pulling the
// JSONL. Throttled (first buffer, then every 500) so a large offload can't flood the strap log; the
// count is a per-connection running total. Off unless raw capture is enabled (gated above).
rawImuDecodedCount++
if (rawImuDecodedCount == 1 || rawImuDecodedCount % 500 == 0) {
log("RAW IMU capture: $rawImuDecodedCount buffer(s) decoded, latest ts=$baseTs " +
- "(${cols.size / 6} samples/axis) — storing (retain ${WhoopRepository.RAW_IMU_RETENTION_ROWS})")
- }
- val row = RawImuSampleEntity(dev, baseTs, StreamPersistence.packImuColumns(cols))
- ioScope.launch {
- runCatching { repository.insertRawImu(dev, listOf(row)) }
- .onFailure { log("RAW IMU capture: store failed (${it.message})") }
+ "(${cols.size / 6} samples/axis) — routed to matching file sessions")
}
}
diff --git a/android/app/src/main/java/com/noop/data/DeviceRegistry.kt b/android/app/src/main/java/com/noop/data/DeviceRegistry.kt
index 22ec1f9cf2..4343190d1e 100644
--- a/android/app/src/main/java/com/noop/data/DeviceRegistry.kt
+++ b/android/app/src/main/java/com/noop/data/DeviceRegistry.kt
@@ -98,7 +98,6 @@ class DeviceRegistry(
dao.reKeySteps(activeId, serialId); dao.deleteStepsFor(activeId)
dao.reKeyPpgHr(activeId, serialId); dao.deletePpgHrFor(activeId)
dao.reKeyPpgWaveform(activeId, serialId); dao.deletePpgWaveformFor(activeId)
- dao.reKeyRawImu(activeId, serialId); dao.deleteRawImuFor(activeId)
dao.reKeyV18Aux(activeId, serialId); dao.deleteV18AuxFor(activeId)
dao.reKeyEvents(activeId, serialId); dao.deleteEventsFor(activeId)
dao.reKeyBattery(activeId, serialId); dao.deleteBatteryFor(activeId)
@@ -188,7 +187,6 @@ class DeviceRegistry(
dao.deleteStepsFor(id)
dao.deletePpgHrFor(id)
dao.deletePpgWaveformFor(id)
- dao.deleteRawImuFor(id) // #423
dao.deleteV18AuxFor(id)
dao.deleteEventsFor(id)
dao.deleteBatteryFor(id)
diff --git a/android/app/src/main/java/com/noop/data/DeviceRegistryDao.kt b/android/app/src/main/java/com/noop/data/DeviceRegistryDao.kt
index 7275f2839d..099edec1b6 100644
--- a/android/app/src/main/java/com/noop/data/DeviceRegistryDao.kt
+++ b/android/app/src/main/java/com/noop/data/DeviceRegistryDao.kt
@@ -85,7 +85,6 @@ interface DeviceRegistryDao {
@Query("DELETE FROM stepSample WHERE deviceId = :deviceId") suspend fun deleteStepsFor(deviceId: String)
@Query("DELETE FROM ppgHrSample WHERE deviceId = :deviceId") suspend fun deletePpgHrFor(deviceId: String)
@Query("DELETE FROM ppgWaveformSample WHERE deviceId = :deviceId") suspend fun deletePpgWaveformFor(deviceId: String)
- @Query("DELETE FROM rawImuSample WHERE deviceId = :deviceId") suspend fun deleteRawImuFor(deviceId: String) // #423
@Query("DELETE FROM v18AuxSample WHERE deviceId = :deviceId") suspend fun deleteV18AuxFor(deviceId: String)
@Query("DELETE FROM event WHERE deviceId = :deviceId") suspend fun deleteEventsFor(deviceId: String)
@Query("DELETE FROM battery WHERE deviceId = :deviceId") suspend fun deleteBatteryFor(deviceId: String)
@@ -126,7 +125,6 @@ interface DeviceRegistryDao {
@Query("UPDATE OR IGNORE stepSample SET deviceId = :to WHERE deviceId = :from") suspend fun reKeySteps(from: String, to: String)
@Query("UPDATE OR IGNORE ppgHrSample SET deviceId = :to WHERE deviceId = :from") suspend fun reKeyPpgHr(from: String, to: String)
@Query("UPDATE OR IGNORE ppgWaveformSample SET deviceId = :to WHERE deviceId = :from") suspend fun reKeyPpgWaveform(from: String, to: String)
- @Query("UPDATE OR IGNORE rawImuSample SET deviceId = :to WHERE deviceId = :from") suspend fun reKeyRawImu(from: String, to: String)
@Query("UPDATE OR IGNORE v18AuxSample SET deviceId = :to WHERE deviceId = :from") suspend fun reKeyV18Aux(from: String, to: String)
@Query("UPDATE OR IGNORE event SET deviceId = :to WHERE deviceId = :from") suspend fun reKeyEvents(from: String, to: String)
@Query("UPDATE OR IGNORE battery SET deviceId = :to WHERE deviceId = :from") suspend fun reKeyBattery(from: String, to: String)
diff --git a/android/app/src/main/java/com/noop/data/Entities.kt b/android/app/src/main/java/com/noop/data/Entities.kt
index 9dd6d58017..5d1ac18c74 100644
--- a/android/app/src/main/java/com/noop/data/Entities.kt
+++ b/android/app/src/main/java/com/noop/data/Entities.kt
@@ -656,42 +656,6 @@ data class PpgWaveformSampleEntity(
}
}
-/**
- * One 1-second WHOOP 5/MG raw-IMU offload buffer (#423): 100 Hz 6-axis inertial data. [samples] is a
- * packed little-endian i16 BLOB of the six columns in wire order — ax×100, ay×100, az×100, gx×100, gy×100,
- * gz×100 (1200 bytes) — decoded by [com.noop.protocol.Whoop5RawImu] (scales 1/4096 g/LSB, 2000/32768 dps/
- * LSB). The strap already delivers this in the connect-time offload burst; capturing it needs NO arming.
- * Instrument-first + bounded: written only when raw capture is enabled, and pruned to a rolling recent
- * window ([WhoopRepository.RAW_IMU_RETENTION_ROWS]). Twin of the GRDB `rawImuSample` table. Natural key
- * (deviceId, ts) = one row per strap-second.
- *
- * CONSUMER STATUS (#978): deliberately none yet — instrument-first, the same stance as `v18AuxSample`. The
- * writer runs only with raw capture enabled + a 5/MG deep-data unlock; nothing scores, gates or shows a row.
- * The [WhoopRepository.rawImuSamples] / [WhoopDao.rawImuSamples] reader is intentionally dormant (zero
- * callers) — the eventual cross-check seam, NOT dead code, so do not delete it. The GRDB twin has no reader
- * yet by the same rule: one lands WITH a validated consumer, not before.
- */
-@Entity(tableName = "rawImuSample", primaryKeys = ["deviceId", "ts"])
-data class RawImuSampleEntity(
- val deviceId: String,
- val ts: Long,
- val samples: ByteArray,
-) {
- // ByteArray needs structural equals/hashCode (the generated identity ones break round-trip asserts).
- override fun equals(other: Any?): Boolean {
- if (this === other) return true
- if (other !is RawImuSampleEntity) return false
- return deviceId == other.deviceId && ts == other.ts && samples.contentEquals(other.samples)
- }
-
- override fun hashCode(): Int {
- var result = deviceId.hashCode()
- result = 31 * result + ts.hashCode()
- result = 31 * result + samples.contentHashCode()
- return result
- }
-}
-
/** Catalog row for one immutable, app-private IMU archive chunk. Payload bytes live outside SQLite. */
@Entity(
tableName = "imuChunk",
diff --git a/android/app/src/main/java/com/noop/data/WhoopDao.kt b/android/app/src/main/java/com/noop/data/WhoopDao.kt
index f470ac2a50..2dbd0c3265 100644
--- a/android/app/src/main/java/com/noop/data/WhoopDao.kt
+++ b/android/app/src/main/java/com/noop/data/WhoopDao.kt
@@ -124,10 +124,6 @@ interface WhoopDao : DeviceRegistryDao {
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertPpgWaveform(rows: List): List
- /** RAW 5/MG IMU offload buffers (packed i16 BLOB). Idempotent by (deviceId, ts). (#423) */
- @Insert(onConflict = OnConflictStrategy.IGNORE)
- suspend fun insertRawImu(rows: List): List
-
/**
* The remaining 5/MG v18 per-second fields, one compact blob per strap-second. Idempotent by
* (deviceId, ts) — IGNORE keeps the FIRST-seen row, matching every other per-second stream, so a
@@ -147,22 +143,6 @@ interface WhoopDao : DeviceRegistryDao {
)
suspend fun pruneV18Aux(deviceId: String, keep: Int)
- /** Bound the raw-IMU table to the newest [keep] rows for [deviceId] (rolling retention, #423). */
- @Query(
- "DELETE FROM rawImuSample WHERE deviceId = :deviceId AND ts < " +
- "(SELECT MIN(ts) FROM (SELECT ts FROM rawImuSample WHERE deviceId = :deviceId ORDER BY ts DESC LIMIT :keep))"
- )
- suspend fun pruneRawImu(deviceId: String, keep: Int)
-
- /** RAW 5/MG IMU buffers in [from, to] (ascending), packed i16 BLOB. (#423)
- * Intentionally dormant — zero callers, retained for the eventual cross-check (see [RawImuSampleEntity]
- * CONSUMER STATUS, #978). Not dead code; do not delete. */
- @Query(
- "SELECT * FROM rawImuSample WHERE deviceId = :deviceId AND ts >= :from AND ts <= :to " +
- "ORDER BY ts ASC LIMIT :limit"
- )
- suspend fun rawImuSamples(deviceId: String, from: Long, to: Long, limit: Int): List
-
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertImuChunk(row: ImuChunkEntity)
@@ -972,7 +952,6 @@ interface WhoopDao : DeviceRegistryDao {
@Query("SELECT COUNT(*) FROM ppgHrSample") suspend fun countPpgHr(): Int
@Query("SELECT COUNT(*) FROM sleepStateSample") suspend fun countSleepState(): Int
@Query("SELECT COUNT(*) FROM ppgWaveformSample") suspend fun countPpgWaveform(): Int
- @Query("SELECT COUNT(*) FROM rawImuSample") suspend fun countRawImu(): Int
@Query("SELECT COUNT(*) FROM v18AuxSample") suspend fun countV18Aux(): Int
@Query("SELECT COUNT(*) FROM respSample") suspend fun countResp(): Int
@Query("SELECT COUNT(*) FROM gravitySample") suspend fun countGravity(): Int
@@ -1035,8 +1014,6 @@ interface WhoopDao : DeviceRegistryDao {
@Query("DELETE FROM ppgWaveformSample WHERE ts < :minTs OR ts > :maxTs")
suspend fun prunePpgWaveformByTs(minTs: Long, maxTs: Long): Int
- @Query("DELETE FROM rawImuSample WHERE ts < :minTs OR ts > :maxTs")
- suspend fun pruneRawImuByTs(minTs: Long, maxTs: Long): Int
@Query("DELETE FROM v18AuxSample WHERE ts < :minTs OR ts > :maxTs")
suspend fun pruneV18AuxByTs(minTs: Long, maxTs: Long): Int
diff --git a/android/app/src/main/java/com/noop/data/WhoopDatabase.kt b/android/app/src/main/java/com/noop/data/WhoopDatabase.kt
index 6dd2b055fb..d1337d1167 100644
--- a/android/app/src/main/java/com/noop/data/WhoopDatabase.kt
+++ b/android/app/src/main/java/com/noop/data/WhoopDatabase.kt
@@ -49,12 +49,11 @@ import androidx.sqlite.db.SupportSQLiteDatabase
LabMarkerRow::class,
LiveSessionRow::class,
PpgWaveformSampleEntity::class,
- RawImuSampleEntity::class,
ImuChunkEntity::class,
V18AuxSampleEntity::class,
AppleStepHour::class,
],
- version = 34,
+ version = 35,
// #775: ON so Room's KSP processor writes the generated schema (every table's exact `CREATE TABLE`,
// columns in declaration order with affinity/NOT NULL/default, PK and indices) as JSON. That export
// is what lets a plain JVM test — no device, no Robolectric — read Android's REAL schema and compare
@@ -71,7 +70,7 @@ abstract class WhoopDatabase : RoomDatabase() {
const val DB_NAME = "noop_whoop.db"
/** Room schema version — MUST equal the `@Database(version = …)` above. Surfaced in the backup
* manifest (#1410) so an export states its schema. Bump both together on a migration. */
- const val SCHEMA_VERSION = 34
+ const val SCHEMA_VERSION = 35
@Volatile
private var instance: WhoopDatabase? = null
@@ -915,6 +914,10 @@ abstract class WhoopDatabase : RoomDatabase() {
}
}
+ internal val MIGRATION_34_35 = object : Migration(34, 35) {
+ override fun migrate(db: SupportSQLiteDatabase) { db.execSQL("DROP TABLE IF EXISTS `rawImuSample`") }
+ }
+
/**
* Every migration the builder registers, as a VALUE rather than an argument list.
*
@@ -940,7 +943,7 @@ abstract class WhoopDatabase : RoomDatabase() {
MIGRATION_18_19, MIGRATION_19_20, MIGRATION_20_21, MIGRATION_21_22,
MIGRATION_22_23, MIGRATION_23_24, MIGRATION_24_25, MIGRATION_25_26,
MIGRATION_26_27, MIGRATION_27_28, MIGRATION_28_29, MIGRATION_29_30,
- MIGRATION_30_31, MIGRATION_31_32, MIGRATION_32_33, MIGRATION_33_34,
+ MIGRATION_30_31, MIGRATION_31_32, MIGRATION_32_33, MIGRATION_33_34, MIGRATION_34_35,
)
diff --git a/android/app/src/main/java/com/noop/data/WhoopRepository.kt b/android/app/src/main/java/com/noop/data/WhoopRepository.kt
index ba989deb21..6f63e45dcd 100644
--- a/android/app/src/main/java/com/noop/data/WhoopRepository.kt
+++ b/android/app/src/main/java/com/noop/data/WhoopRepository.kt
@@ -795,7 +795,6 @@ class WhoopRepository(
// The instrumentation streams, missing from this list since they landed (see the DAO note).
deleted += dao.pruneSleepStateByTs(minTs, maxTs)
deleted += dao.prunePpgWaveformByTs(minTs, maxTs)
- deleted += dao.pruneRawImuByTs(minTs, maxTs)
deleted += dao.pruneV18AuxByTs(minTs, maxTs)
deleted += dao.pruneEventByTs(minTs, maxTs)
deleted += dao.pruneBatteryByTs(minTs, maxTs)
@@ -1004,23 +1003,6 @@ class WhoopRepository(
dao.v18AuxSamples(deviceId, from, to, limit)
.map { V18AuxCodec.unpack(it.fields, it.ts) }
- /** #423: persist a batch of decoded 5/MG raw-IMU offload buffers (one row per strap-second, packed i16
- * BLOB), then bound the table to the newest [RAW_IMU_RETENTION_ROWS] for the device. Comes from the
- * deep-buffer capture seam, not the normal stream path, so it inserts directly (idempotent by ts). */
- suspend fun insertRawImu(deviceId: String, rows: List) {
- if (rows.isEmpty()) return
- dao.insertRawImu(rows)
- dao.pruneRawImu(deviceId, RAW_IMU_RETENTION_ROWS)
- }
-
- /** #423: raw 5/MG IMU buffers in [from, to] as the decoded i16 columns [ax…az,gx…gz] (100/axis).
- * Intentionally dormant — zero callers, retained for the eventual cross-check (see [RawImuSampleEntity]
- * CONSUMER STATUS, #978). Not dead code; do not delete. */
- suspend fun rawImuSamples(deviceId: String, from: Long, to: Long, limit: Int = DEFAULT_LIMIT):
- List> =
- dao.rawImuSamples(deviceId, from, to, limit)
- .map { it.ts to StreamPersistence.unpackImuColumns(it.samples) }
-
suspend fun upsertImuChunk(row: ImuChunkEntity) = dao.upsertImuChunk(row)
suspend fun imuChunks(deviceId: String, from: Long, to: Long): List =
dao.imuChunks(deviceId, from, to)
@@ -1576,9 +1558,9 @@ class WhoopRepository(
"resp" to dao.countResp(), "gravity" to dao.countGravity(),
// The rest of the accumulating decoded raw streams, so the Test-Centre footprint counts ALL of
// them (keep in sync with Swift storageStats / TimestampHeal's raw-table list). ppgHr/ppgWaveform
- // /rawImu can each be large.
+ // /v18Aux can each be large.
"ppgHr" to dao.countPpgHr(), "sleepState" to dao.countSleepState(),
- "ppgWaveform" to dao.countPpgWaveform(), "rawImu" to dao.countRawImu(),
+ "ppgWaveform" to dao.countPpgWaveform(),
"v18Aux" to dao.countV18Aux(),
)
}.getOrDefault(emptyMap())
@@ -1967,12 +1949,6 @@ class WhoopRepository(
/** Default row cap on range reads. Matches the Swift call sites' bounded scans. */
const val DEFAULT_LIMIT = 100_000
- /** #423: rolling retention for the raw-IMU capture table (1 row/strap-second, ~1.2 KB each). One
- * hour ≈ 3600 rows ≈ 4 MB caps the table hard, so an enabled capture can never balloon the DB
- * during a multi-day offload replay. Instrument-first bounded window; nothing consumes it yet. */
- /** 36 h rolling cache: enough for retrospective activity/workout windows without permanent growth. */
- const val RAW_IMU_RETENTION_ROWS = 36 * 60 * 60
-
/**
* v31: rolling retention for the v18 aux-slot table (Swift twin `WhoopStore.v18AuxRetentionRows`).
*
diff --git a/android/app/src/main/java/com/noop/testcentre/GroundTruthCollector.kt b/android/app/src/main/java/com/noop/testcentre/GroundTruthCollector.kt
index f5824b8168..05d4132e91 100644
--- a/android/app/src/main/java/com/noop/testcentre/GroundTruthCollector.kt
+++ b/android/app/src/main/java/com/noop/testcentre/GroundTruthCollector.kt
@@ -60,7 +60,7 @@ class GroundTruthCollector private constructor(private val context: Context) {
private val directory = File(context.filesDir, "ground-truth").apply { mkdirs() }
fun realtimeImuBytes(sessionId: String): Long =
- File(directory, "realtime-imu-$sessionId.bin").takeIf(File::isFile)?.length() ?: 0L
+ File(directory, "realtime-imu-$sessionId.imus").takeIf(File::isFile)?.length() ?: 0L
@Synchronized
fun snapshot(): Snapshot = Snapshot(
@@ -97,6 +97,7 @@ class GroundTruthCollector private constructor(private val context: Context) {
.putBoolean(sessionCaptureKey(id), true)
.apply()
append(id, event(nowMs, "start", 0, 0, noopSteps, null).put("strap_device_id", deviceId))
+ ImuSessionFileStore(context).start(id, deviceId, nowMs)
return snapshot()
}
@@ -138,7 +139,7 @@ class GroundTruthCollector private constructor(private val context: Context) {
val physicalStart = prefs.getLong(sessionStartKey(id), first.optLong("at_ms"))
val capturedEnd = rows.lastOrNull { it.optString("kind") == "stop" }?.optLong("at_ms")
val hasCapture = prefs.getBoolean(sessionCaptureKey(id),
- File(directory, "realtime-imu-$id.bin").isFile)
+ File(directory, "realtime-imu-$id.imus").isFile)
SessionSummary(
id = id,
deviceId = prefs.getString(sessionDeviceKey(id), null)
@@ -167,12 +168,13 @@ class GroundTruthCollector private constructor(private val context: Context) {
val files = listOf(
eventFile(sessionId),
- File(directory, "realtime-imu-$sessionId.bin"),
+ File(directory, "realtime-imu-$sessionId.imus"),
File(context.cacheDir, "logs/noop-ground-truth-$sessionId.zip"),
)
// Evaluate every deletion even if one fails, so a stale export ZIP never survives merely
// because an earlier file could not be removed.
val filesDeleted = files.map { file -> !file.exists() || file.delete() }.all { it }
+ ImuSessionFileStore(context).remove(sessionId)
if (eventFile(sessionId).exists()) return false
prefs.edit()
@@ -198,6 +200,8 @@ class GroundTruthCollector private constructor(private val context: Context) {
if (session.active || fromMs <= 0 || fromMs > toMs || toMs - fromMs > MAX_RANGE_MS) return false
prefs.edit().putLong(sessionUsedStartKey(sessionId), fromMs)
.putLong(sessionUsedEndKey(sessionId), toMs).apply()
+ val deviceId = session.deviceId ?: return false
+ ImuSessionFileStore(context).register(sessionId, deviceId, fromMs, toMs)
return true
}
@@ -210,6 +214,7 @@ class GroundTruthCollector private constructor(private val context: Context) {
.putLong(sessionUsedEndKey(id), toMs).putBoolean(sessionCaptureKey(id), false).apply()
append(id, event(fromMs, "start", 0, 0, null, null).put("strap_device_id", deviceId))
append(id, event(toMs, "stop", 0, 0, null, null))
+ ImuSessionFileStore(context).register(id, deviceId, fromMs, toMs)
return sessions().firstOrNull { it.id == id }
}
@@ -270,6 +275,7 @@ class GroundTruthCollector private constructor(private val context: Context) {
val before = snapshot()
if (!before.active || before.sessionId == null) return before
append(before.sessionId, event(nowMs, "stop", before.steps, before.stairs, noopSteps, null))
+ ImuSessionFileStore(context).complete(before.sessionId, nowMs)
prefs.edit().putBoolean("active", false).putLong("endedAtMs", nowMs)
.putLong(sessionEndKey(before.sessionId), nowMs)
.applyNoop(noopSteps).apply()
@@ -296,7 +302,8 @@ class GroundTruthCollector private constructor(private val context: Context) {
val sensorFrom = if (deviceId == null) 1L else summary.startedAtMs / 1_000L
val sensorTo = if (deviceId == null) 0L else endMs / 1_000L
val pinnedChunks = if (deviceId == null) emptyList() else
- ImuChunkStore(context, repo).pin(deviceId, sensorFrom, sensorTo)
+ ImuChunkStore(context, repo).pin(id, deviceId, sensorFrom, sensorTo)
+ val imuComplete = covers(pinnedChunks, sensorFrom, sensorTo)
val outDir = File(context.cacheDir, "logs").apply { mkdirs() }
val zip = File(outDir, "noop-ground-truth-$id.zip")
ZipOutputStream(zip.outputStream().buffered()).use { out ->
@@ -308,7 +315,7 @@ class GroundTruthCollector private constructor(private val context: Context) {
if (deviceId != null) put("device_id", deviceId)
put("sensor_export_available", deviceId != null)
put("imu_100hz_chunk_count", pinnedChunks.size)
- put("imu_100hz_complete", covers(pinnedChunks.map { it.startTs to it.endTs }, sensorFrom, sensorTo))
+ put("imu_100hz_complete", imuComplete)
put("imu_100hz_coverage", org.json.JSONArray().apply {
pinnedChunks.forEach { chunk -> put(JSONObject().apply {
put("start_ts", chunk.startTs); put("end_ts", chunk.endTs)
@@ -373,19 +380,6 @@ class GroundTruthCollector private constructor(private val context: Context) {
}
out.closeEntry()
- out.putNextEntry(ZipEntry("raw-imu.csv"))
- out.bufferedWriterNoClose().use { writer ->
- writeRawImuCsv(writer, repo, deviceId.orEmpty(), sensorFrom, sensorTo)
- writer.flush()
- }
- out.closeEntry()
-
- val realtimeImu = File(directory, "realtime-imu-$id.bin")
- if (realtimeImu.isFile) {
- out.putNextEntry(ZipEntry("realtime-imu.bin"))
- realtimeImu.inputStream().use { it.copyTo(out) }
- out.closeEntry()
- }
for (chunk in pinnedChunks) {
val file = ImuChunkStore(context, repo).file(chunk)
if (!file.isFile) continue
@@ -395,6 +389,10 @@ class GroundTruthCollector private constructor(private val context: Context) {
}
}
prefs.edit().putBoolean(sessionExportedKey(id), true).apply()
+ if (imuComplete) {
+ ImuSessionFileStore(context).remove(id)
+ File(directory, "realtime-imu-$id.imus").delete()
+ }
if (id == snap.sessionId) prefs.edit().putBoolean("exported", true).apply()
zip
}
@@ -409,9 +407,11 @@ class GroundTruthCollector private constructor(private val context: Context) {
}, "Export raw-data session").addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
}
- private fun covers(intervals: List>, from: Long, to: Long): Boolean {
+ private fun covers(chunks: List, from: Long, to: Long): Boolean {
var cursor = from
- for ((start, end) in intervals.sortedBy { it.first }) {
+ for (chunk in chunks.sortedBy { it.startTs }) {
+ val start = chunk.startTs; val end = chunk.endTs
+ if (chunk.sampleCount < (end - start + 1) * chunk.sampleRate) continue
if (start > cursor) return false
if (end >= cursor) cursor = end + 1
if (cursor > to) return true
@@ -538,32 +538,6 @@ class GroundTruthCollector private constructor(private val context: Context) {
private fun decimal(value: Double): String = String.format(java.util.Locale.US, "%.9f", value)
- private suspend fun writeRawImuCsv(
- out: java.io.Writer,
- repo: WhoopRepository,
- deviceId: String,
- from: Long,
- to: Long,
- ) {
- out.write("unix_s,sample_index,ax_g,ay_g,az_g,gx_dps,gy_dps,gz_dps\n")
- if (from > to) return
- val secondsInWindow = (to - from + 1L).coerceIn(1L, 86_400L).toInt()
- for ((ts, columns) in repo.rawImuSamples(deviceId, from, to, limit = secondsInWindow)) {
- if (columns.size < Whoop5RawImu.sampleCount * 6) continue
- for (i in 0 until Whoop5RawImu.sampleCount) {
- val values = listOf(
- columns[i] * Whoop5RawImu.accelScale,
- columns[Whoop5RawImu.sampleCount + i] * Whoop5RawImu.accelScale,
- columns[Whoop5RawImu.sampleCount * 2 + i] * Whoop5RawImu.accelScale,
- columns[Whoop5RawImu.sampleCount * 3 + i] * Whoop5RawImu.gyroScale,
- columns[Whoop5RawImu.sampleCount * 4 + i] * Whoop5RawImu.gyroScale,
- columns[Whoop5RawImu.sampleCount * 5 + i] * Whoop5RawImu.gyroScale,
- ).joinToString(",") { String.format(java.util.Locale.US, "%.8f", it) }
- out.write("$ts,$i,$values\n")
- }
- }
- }
-
private fun encodeKey(key: KeyInfo) = JSONObject().apply {
put("keyCode", key.keyCode); put("keyName", key.keyName); put("scanCode", key.scanCode)
put("deviceId", key.deviceId); put("deviceName", key.deviceName); put("source", key.source)
diff --git a/android/app/src/main/java/com/noop/testcentre/ImuChunkStore.kt b/android/app/src/main/java/com/noop/testcentre/ImuChunkStore.kt
index bd12e8482a..7ab3583bb3 100644
--- a/android/app/src/main/java/com/noop/testcentre/ImuChunkStore.kt
+++ b/android/app/src/main/java/com/noop/testcentre/ImuChunkStore.kt
@@ -4,28 +4,34 @@ import android.content.Context
import com.noop.data.ImuChunkEntity
import com.noop.data.StreamPersistence
import com.noop.data.WhoopRepository
+import com.noop.protocol.Whoop5RawImu
import java.io.BufferedOutputStream
+import java.io.DataInputStream
import java.io.DataOutputStream
+import java.io.EOFException
import java.io.File
import java.io.FileOutputStream
import java.security.MessageDigest
import java.util.UUID
+import java.util.zip.Inflater
import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream
import org.json.JSONObject
-/** Materializes immutable, portable IMU archives from the bounded SQLite rolling cache. */
+/** Materializes immutable archives from a session file; SQLite is only a short history staging cache. */
class ImuChunkStore(private val context: Context, private val repository: WhoopRepository) {
private val directory = File(context.filesDir, "imu-chunks").apply { mkdirs() }
- suspend fun pin(deviceId: String, from: Long, to: Long): List {
+ suspend fun pin(sessionId: String, deviceId: String, from: Long, to: Long): List {
require(from <= to)
val existing = repository.imuChunks(deviceId, from, to)
.filter { it.pinnedUntil == Long.MAX_VALUE && File(context.filesDir, it.relativePath).isFile }
- if (existing.any { it.startTs <= from && it.endTs >= to }) return existing
- val limit = (to - from + 1).coerceIn(1, MAX_EXPORT_SECONDS).toInt()
- val rows = repository.rawImuSamples(deviceId, from, to, limit)
+ if (existing.any { it.startTs <= from && it.endTs >= to &&
+ it.sampleCount >= (it.endTs - it.startTs + 1) * it.sampleRate }) return existing
+ val rows = readSessionRows(sessionId, from, to)
if (rows.isEmpty()) return existing
+ val startTs = rows.minOf { it.first }
+ val endTs = rows.maxOf { it.first }
val id = UUID.randomUUID().toString()
val final = File(directory, "$id.imuc")
@@ -38,9 +44,11 @@ class ImuChunkStore(private val context: Context, private val repository: WhoopR
put("sample_rate_hz", SAMPLE_RATE)
put("axes", listOf("ax", "ay", "az", "gx", "gy", "gz"))
put("layout", "column-major-int16-le")
+ put("ordering", "unspecified")
+ put("timestamp_source", "strap")
put("row_count", rows.size)
- put("start_ts", rows.first().first)
- put("end_ts", rows.last().first)
+ put("start_ts", startTs)
+ put("end_ts", endTs)
}.toString(2).toByteArray())
zip.closeEntry()
zip.putNextEntry(ZipEntry("samples.bin"))
@@ -56,7 +64,7 @@ class ImuChunkStore(private val context: Context, private val repository: WhoopR
}
check(temp.renameTo(final)) { "Could not commit IMU chunk" }
val entity = ImuChunkEntity(
- id = id, deviceId = deviceId, startTs = rows.first().first, endTs = rows.last().first,
+ id = id, deviceId = deviceId, startTs = startTs, endTs = endTs,
sampleCount = rows.size * SAMPLE_RATE, sampleRate = SAMPLE_RATE,
formatVersion = FORMAT_VERSION, codec = "zip-deflate",
relativePath = final.relativeTo(context.filesDir).path, byteSize = final.length(),
@@ -72,6 +80,30 @@ class ImuChunkStore(private val context: Context, private val repository: WhoopR
fun file(chunk: ImuChunkEntity) = File(context.filesDir, chunk.relativePath)
+ private fun readSessionRows(sessionId: String, from: Long, to: Long): List> {
+ val source = File(context.filesDir, "ground-truth/realtime-imu-$sessionId.imus")
+ if (!source.isFile) return emptyList()
+ val rows = ArrayList>()
+ DataInputStream(source.inputStream().buffered()).use { input ->
+ while (true) {
+ try {
+ val ts = input.readLong(); input.readLong()
+ val length = input.readInt(); val compressedLength = input.readInt()
+ if (length <= 0 || length > MAX_FRAME_BYTES || compressedLength <= 0 || compressedLength > MAX_FRAME_BYTES) break
+ val compressed = ByteArray(compressedLength); input.readFully(compressed)
+ val inflater = Inflater(); inflater.setInput(compressed)
+ val frame = ByteArray(length); val inflated = inflater.inflate(frame); inflater.end()
+ if (inflated != length) continue
+ val decoded = Whoop5RawImu.decode(frame) ?: continue
+ if (ts == decoded.baseTs && ts in from..to) {
+ Whoop5RawImu.rawColumns(frame)?.let { rows += ts to it }
+ }
+ } catch (_: EOFException) { break }
+ }
+ }
+ return rows
+ }
+
private fun sha256(file: File): String {
val digest = MessageDigest.getInstance("SHA-256")
file.inputStream().buffered().use { input ->
@@ -89,5 +121,6 @@ class ImuChunkStore(private val context: Context, private val repository: WhoopR
const val FORMAT_VERSION = 1
const val SAMPLE_RATE = 100
const val MAX_EXPORT_SECONDS = 7 * 24 * 60 * 60L
+ const val MAX_FRAME_BYTES = 1024 * 1024
}
}
diff --git a/android/app/src/main/java/com/noop/testcentre/ImuSessionFileStore.kt b/android/app/src/main/java/com/noop/testcentre/ImuSessionFileStore.kt
new file mode 100644
index 0000000000..da094b1d5b
--- /dev/null
+++ b/android/app/src/main/java/com/noop/testcentre/ImuSessionFileStore.kt
@@ -0,0 +1,83 @@
+package com.noop.testcentre
+
+import android.content.Context
+import com.noop.protocol.Whoop5RawImu
+import java.io.BufferedOutputStream
+import java.io.DataOutputStream
+import java.io.File
+import java.io.FileOutputStream
+import java.util.zip.Deflater
+
+/** Routes realtime and delayed history IMU frames into persisted session time windows. */
+class ImuSessionFileStore(private val context: Context) {
+ private val prefs = context.getSharedPreferences("imu-session-windows", Context.MODE_PRIVATE)
+ private val directory = File(context.filesDir, "ground-truth").apply { mkdirs() }
+
+ fun start(id: String, deviceId: String, fromMs: Long) = synchronized(lock) {
+ prefs.edit().putStringSet("ids", prefs.getStringSet("ids", emptySet()).orEmpty() + id)
+ .putString("$id.device", deviceId).putLong("$id.from", fromMs / 1_000L).remove("$id.to").apply()
+ }
+
+ fun complete(id: String, toMs: Long) = synchronized(lock) {
+ if (id in prefs.getStringSet("ids", emptySet()).orEmpty())
+ prefs.edit().putLong("$id.to", toMs / 1_000L).apply()
+ }
+
+ fun register(id: String, deviceId: String, fromMs: Long, toMs: Long) = synchronized(lock) {
+ start(id, deviceId, fromMs); complete(id, toMs)
+ }
+
+ fun remove(id: String) = synchronized(lock) {
+ prefs.edit().putStringSet("ids", prefs.getStringSet("ids", emptySet()).orEmpty() - id)
+ .remove("$id.device").remove("$id.from").remove("$id.to").apply()
+ }
+
+ /**
+ * Append one independently-deflated wire frame. The timestamp header makes the stream scanable and
+ * idempotent without inflating older records; delayed history may therefore fill gaps in any order.
+ */
+ fun append(deviceId: String, frame: ByteArray, receivedAtMs: Long = System.currentTimeMillis()): Int = synchronized(lock) {
+ val ts = Whoop5RawImu.decode(frame)?.baseTs ?: return 0
+ var writes = 0
+ for (id in prefs.getStringSet("ids", emptySet()).orEmpty()) {
+ if (prefs.getString("$id.device", null) != deviceId) continue
+ val from = prefs.getLong("$id.from", Long.MAX_VALUE)
+ val to = if (prefs.contains("$id.to")) prefs.getLong("$id.to", Long.MIN_VALUE) else Long.MAX_VALUE
+ if (ts !in from..to) continue
+ val file = File(directory, "realtime-imu-$id.imus")
+ val timestamps = timestamps(file)
+ if (ts in timestamps) continue
+ val deflater = Deflater()
+ val compressed = ByteArray(frame.size + 64)
+ deflater.setInput(frame); deflater.finish()
+ val compressedSize = deflater.deflate(compressed); deflater.end()
+ DataOutputStream(BufferedOutputStream(FileOutputStream(file, true))).use {
+ it.writeLong(ts); it.writeLong(receivedAtMs); it.writeInt(frame.size); it.writeInt(compressedSize)
+ it.write(compressed, 0, compressedSize)
+ }
+ timestamps += ts
+ writes++
+ }
+ return writes
+ }
+
+ private fun timestamps(file: File): MutableSet = seen.getOrPut(file.absolutePath) {
+ val result = mutableSetOf()
+ if (!file.exists()) return@getOrPut result
+ java.io.DataInputStream(file.inputStream().buffered()).use { input ->
+ while (input.available() >= 24) {
+ val ts = input.readLong(); input.readLong(); input.readInt()
+ val compressed = input.readInt()
+ if (compressed < 0 || compressed > 1_048_576 || input.available() < compressed) break
+ result += ts
+ input.skipBytes(compressed)
+ }
+ }
+ result
+ }
+
+ companion object {
+ private val lock = Any()
+ private val seen = mutableMapOf>()
+ }
+}
diff --git a/android/app/src/main/java/com/noop/ui/GroundTruthCollectorScreen.kt b/android/app/src/main/java/com/noop/ui/GroundTruthCollectorScreen.kt
index 616e4cdd36..091f78db19 100644
--- a/android/app/src/main/java/com/noop/ui/GroundTruthCollectorScreen.kt
+++ b/android/app/src/main/java/com/noop/ui/GroundTruthCollectorScreen.kt
@@ -274,6 +274,7 @@ fun GroundTruthCollectorScreen(vm: AppViewModel) {
scope.launch {
try {
collector.share(collector.export(vm.repo, session.id))
+ vm.ble.finishGroundTruthImuCapture(session.id)
sessions = collector.sessions()
} catch (failure: Throwable) {
if (failure is kotlinx.coroutines.CancellationException) throw failure
@@ -316,6 +317,7 @@ fun GroundTruthCollectorScreen(vm: AppViewModel) {
},
confirmButton = {
TextButton(onClick = {
+ vm.ble.finishGroundTruthImuCapture(session.id)
collector.deleteSession(session.id)
sessions = collector.sessions()
state = collector.snapshot()
@@ -339,6 +341,7 @@ fun GroundTruthCollectorScreen(vm: AppViewModel) {
},
confirmButton = {
TextButton(onClick = {
+ sessions.forEach { vm.ble.finishGroundTruthImuCapture(it.id) }
collector.deleteAllSessions()
sessions = collector.sessions()
state = collector.snapshot()
diff --git a/android/app/src/test/java/com/noop/analytics/RegistryDayOwnerSourceTest.kt b/android/app/src/test/java/com/noop/analytics/RegistryDayOwnerSourceTest.kt
index dc0008b194..78adb90f68 100644
--- a/android/app/src/test/java/com/noop/analytics/RegistryDayOwnerSourceTest.kt
+++ b/android/app/src/test/java/com/noop/analytics/RegistryDayOwnerSourceTest.kt
@@ -66,7 +66,6 @@ class RegistryDayOwnerSourceTest {
override suspend fun deleteStepsFor(deviceId: String) {}
override suspend fun deletePpgHrFor(deviceId: String) {}
override suspend fun deletePpgWaveformFor(deviceId: String) {}
- override suspend fun deleteRawImuFor(deviceId: String) {}
override suspend fun deleteV18AuxFor(deviceId: String) {}
override suspend fun deleteEventsFor(deviceId: String) {}
override suspend fun deleteBatteryFor(deviceId: String) {}
@@ -97,7 +96,6 @@ class RegistryDayOwnerSourceTest {
override suspend fun reKeySteps(from: String, to: String) {}
override suspend fun reKeyPpgHr(from: String, to: String) {}
override suspend fun reKeyPpgWaveform(from: String, to: String) {}
- override suspend fun reKeyRawImu(from: String, to: String) {}
override suspend fun reKeyV18Aux(from: String, to: String) {}
override suspend fun reKeyEvents(from: String, to: String) {}
override suspend fun reKeyBattery(from: String, to: String) {}
diff --git a/android/app/src/test/java/com/noop/ble/SourceCoordinatorAdoptionTest.kt b/android/app/src/test/java/com/noop/ble/SourceCoordinatorAdoptionTest.kt
index d86a7dba31..573cd4006b 100644
--- a/android/app/src/test/java/com/noop/ble/SourceCoordinatorAdoptionTest.kt
+++ b/android/app/src/test/java/com/noop/ble/SourceCoordinatorAdoptionTest.kt
@@ -81,7 +81,6 @@ class SourceCoordinatorAdoptionTest {
override suspend fun deleteStepsFor(deviceId: String) {}
override suspend fun deletePpgHrFor(deviceId: String) {}
override suspend fun deletePpgWaveformFor(deviceId: String) {}
- override suspend fun deleteRawImuFor(deviceId: String) {}
override suspend fun deleteV18AuxFor(deviceId: String) {}
override suspend fun deleteEventsFor(deviceId: String) {}
override suspend fun deleteBatteryFor(deviceId: String) {}
@@ -114,7 +113,6 @@ class SourceCoordinatorAdoptionTest {
override suspend fun reKeySteps(from: String, to: String) {}
override suspend fun reKeyPpgHr(from: String, to: String) {}
override suspend fun reKeyPpgWaveform(from: String, to: String) {}
- override suspend fun reKeyRawImu(from: String, to: String) {}
override suspend fun reKeyV18Aux(from: String, to: String) {}
override suspend fun reKeyEvents(from: String, to: String) {}
override suspend fun reKeyBattery(from: String, to: String) {}
diff --git a/android/app/src/test/java/com/noop/data/DeepCaptureMigrationTest.kt b/android/app/src/test/java/com/noop/data/DeepCaptureMigrationTest.kt
index 845917fe5e..592dca23f0 100644
--- a/android/app/src/test/java/com/noop/data/DeepCaptureMigrationTest.kt
+++ b/android/app/src/test/java/com/noop/data/DeepCaptureMigrationTest.kt
@@ -220,6 +220,5 @@ class DeepCaptureMigrationTest {
@Test
fun shippedRetentionConstant() {
assertEquals(604_800, WhoopRepository.V18_AUX_RETENTION_ROWS) // 7 x 86_400 strap-seconds
- assertTrue(WhoopRepository.V18_AUX_RETENTION_ROWS > WhoopRepository.RAW_IMU_RETENTION_ROWS)
}
}
diff --git a/android/app/src/test/java/com/noop/data/DeviceRegistryTest.kt b/android/app/src/test/java/com/noop/data/DeviceRegistryTest.kt
index fa9b475580..6c747b8520 100644
--- a/android/app/src/test/java/com/noop/data/DeviceRegistryTest.kt
+++ b/android/app/src/test/java/com/noop/data/DeviceRegistryTest.kt
@@ -97,7 +97,6 @@ class DeviceRegistryTest {
override suspend fun deleteStepsFor(deviceId: String) { deletedTables += "stepSample" to deviceId }
override suspend fun deletePpgHrFor(deviceId: String) { deletedTables += "ppgHrSample" to deviceId }
override suspend fun deletePpgWaveformFor(deviceId: String) { deletedTables += "ppgWaveformSample" to deviceId }
- override suspend fun deleteRawImuFor(deviceId: String) { deletedTables += "rawImuSample" to deviceId }
override suspend fun deleteV18AuxFor(deviceId: String) { deletedTables += "v18AuxSample" to deviceId }
override suspend fun deleteEventsFor(deviceId: String) { deletedTables += "event" to deviceId }
override suspend fun deleteBatteryFor(deviceId: String) { deletedTables += "battery" to deviceId }
@@ -134,7 +133,6 @@ class DeviceRegistryTest {
override suspend fun reKeySteps(from: String, to: String) {}
override suspend fun reKeyPpgHr(from: String, to: String) {}
override suspend fun reKeyPpgWaveform(from: String, to: String) {}
- override suspend fun reKeyRawImu(from: String, to: String) {}
override suspend fun reKeyV18Aux(from: String, to: String) {}
override suspend fun reKeyEvents(from: String, to: String) {}
override suspend fun reKeyBattery(from: String, to: String) {}
@@ -312,7 +310,7 @@ class DeviceRegistryTest {
// were missing, leaving raw sleep-state, lab markers, live sessions and dismissed markers behind.
val expectedTables = setOf(
"hrSample", "rrInterval", "spo2Sample", "skinTempSample", "respSample", "gravitySample",
- "stepSample", "ppgHrSample", "ppgWaveformSample", "rawImuSample", "v18AuxSample",
+ "stepSample", "ppgHrSample", "ppgWaveformSample", "v18AuxSample",
"event", "battery", "dailyMetric", "sleepSession",
"journal", "workout", "appleDaily", "metricSeries", "dayOwnership",
"scoreInputProvenance",
diff --git a/android/app/src/test/java/com/noop/data/RawImuMigrationTest.kt b/android/app/src/test/java/com/noop/data/RawImuMigrationTest.kt
index 57b5075853..960bba5ae0 100644
--- a/android/app/src/test/java/com/noop/data/RawImuMigrationTest.kt
+++ b/android/app/src/test/java/com/noop/data/RawImuMigrationTest.kt
@@ -1,13 +1,11 @@
package com.noop.data
import com.noop.protocol.Whoop5RawImu
-import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
-import java.lang.reflect.Proxy
/**
* Guards the additive v20 -> v21 Room migration (the `rawImuSample` table, #423), the Android twin of the
@@ -54,6 +52,11 @@ class RawImuMigrationTest {
assertEquals(21, WhoopDatabase.MIGRATION_20_21.endVersion)
}
+ @Test fun obsoleteTable_isDroppedAt35() {
+ assertEquals(34, WhoopDatabase.MIGRATION_34_35.startVersion)
+ assertEquals(35, WhoopDatabase.MIGRATION_34_35.endVersion)
+ }
+
// MARK: - Packed-BLOB encoding (byte-identical i16 LE)
@Test
@@ -100,45 +103,6 @@ class RawImuMigrationTest {
assertNull("wrong sample count", Whoop5RawImu.rawColumns(f))
}
- // MARK: - Store-write plumbing (repository inserts + rolling prune through the DAO)
-
- @Test
- fun repositoryInsertRawImu_insertsThenPrunes() = runBlocking {
- var inserted: List? = null
- var prunedDevice: String? = null
- var prunedKeep = -1
- val dao = Proxy.newProxyInstance(
- WhoopDao::class.java.classLoader,
- arrayOf(WhoopDao::class.java),
- ) { _, method, args ->
- when (method.name) {
- "insertRawImu" -> {
- @Suppress("UNCHECKED_CAST")
- inserted = args[0] as List
- listOf(1L)
- }
- "pruneRawImu" -> { prunedDevice = args[0] as String; prunedKeep = args[1] as Int; Unit }
- else -> throw UnsupportedOperationException("raw-imu insert must not call ${method.name}")
- }
- } as WhoopDao
-
- val row = RawImuSampleEntity("my-whoop", 1_780_917_232L, StreamPersistence.packImuColumns(shortArrayOf(1, -1, 100)))
- WhoopRepository(dao).insertRawImu("my-whoop", listOf(row))
-
- assertEquals(listOf(row), inserted)
- assertEquals("my-whoop", prunedDevice)
- assertEquals(WhoopRepository.RAW_IMU_RETENTION_ROWS, prunedKeep)
- }
-
- @Test
- fun repositoryInsertRawImu_emptyIsNoOp() = runBlocking {
- val dao = Proxy.newProxyInstance(
- WhoopDao::class.java.classLoader,
- arrayOf(WhoopDao::class.java),
- ) { _, method, _ -> throw AssertionError("empty insert must not touch the DAO (${method.name})") } as WhoopDao
- WhoopRepository(dao).insertRawImu("my-whoop", emptyList())
- }
-
private fun validImuBuffer(): ByteArray {
val f = ByteArray(Whoop5RawImu.bufferLength)
putI16(f, 24, 100) // countA
diff --git a/android/app/src/test/java/com/noop/testcentre/RawDataCollectorParityTest.kt b/android/app/src/test/java/com/noop/testcentre/RawDataCollectorParityTest.kt
index b5449ef323..5c222e0ed2 100644
--- a/android/app/src/test/java/com/noop/testcentre/RawDataCollectorParityTest.kt
+++ b/android/app/src/test/java/com/noop/testcentre/RawDataCollectorParityTest.kt
@@ -26,6 +26,8 @@ class RawDataCollectorParityTest {
val source = listOf(
"android/app/src/main/java/com/noop/testcentre/GroundTruthCollector.kt",
"android/app/src/main/java/com/noop/testcentre/ImuChunkStore.kt",
+ "android/app/src/main/java/com/noop/testcentre/ImuSessionFileStore.kt",
+ "android/app/src/main/java/com/noop/data/WhoopDatabase.kt",
"android/app/src/main/java/com/noop/data/Entities.kt",
"android/app/src/main/java/com/noop/data/WhoopRepository.kt",
"android/app/src/main/java/com/noop/ui/GroundTruthCollectorScreen.kt",
diff --git a/android/app/src/test/resources/raw_data_collector_parity.json b/android/app/src/test/resources/raw_data_collector_parity.json
index 3129d3d7e9..e3c13ecb28 100644
--- a/android/app/src/test/resources/raw_data_collector_parity.json
+++ b/android/app/src/test/resources/raw_data_collector_parity.json
@@ -41,9 +41,13 @@
"swift": ["func setRange(sessionId:", "DatePicker(\"From\""],
"kotlin": ["fun setSessionRange(", "ground_truth_edit_start"]
},
- "bounded_36h_imu_cache": {
- "swift": ["rawImuRetentionRows = 36 * 60 * 60"],
- "kotlin": ["RAW_IMU_RETENTION_ROWS = 36 * 60 * 60"]
+ "file_only_imu_storage": {
+ "swift": ["v41-drop-raw-imu-sample"],
+ "kotlin": ["MIGRATION_34_35", "DROP TABLE IF EXISTS `rawImuSample`"]
+ },
+ "session_bound_imu_file": {
+ "swift": ["ImuSessionFileStore", "realtime-imu-\\(id).imus"],
+ "kotlin": ["ImuSessionFileStore", "realtime-imu-$id.imus"]
},
"durable_imu_chunk_catalog": {
"swift": ["ImuChunkArchiveStore", "ImuChunkMeta"],
@@ -67,7 +71,7 @@
},
"raw_data_removed_with_session": {
"swift": ["deleteRawBatches(deviceId:", "capturedAt >= ? AND capturedAt <= ?"],
- "kotlin": ["realtime-imu-$sessionId.bin", "noop-ground-truth-$sessionId.zip"]
+ "kotlin": ["realtime-imu-$sessionId.imus", "noop-ground-truth-$sessionId.zip"]
}
}
}
diff --git a/android/app/src/test/resources/schema_oracle.json b/android/app/src/test/resources/schema_oracle.json
index bef71e5b5b..02b401e3e4 100644
--- a/android/app/src/test/resources/schema_oracle.json
+++ b/android/app/src/test/resources/schema_oracle.json
@@ -1276,34 +1276,6 @@
{ "name": "index_imuChunk_deviceId_startTs_endTs", "columns": ["deviceId", "startTs", "endTs"], "unique": false }
]
},
- "rawImuSample": {
- "platform": "both",
- "columns": [
- {
- "name": "deviceId",
- "affinity": "TEXT",
- "notNull": true,
- "default": null
- },
- {
- "name": "ts",
- "affinity": "INTEGER",
- "notNull": true,
- "default": null
- },
- {
- "name": "samples",
- "affinity": "BLOB",
- "notNull": true,
- "default": null
- }
- ],
- "primaryKey": [
- "deviceId",
- "ts"
- ],
- "indices": []
- },
"respSample": {
"platform": "both",
"columns": [
From 5495f7b48298fd7936b4da5bfa30b76d201d7913 Mon Sep 17 00:00:00 2001
From: Bernd Helm
Date: Wed, 26 Aug 2026 22:50:39 +0200
Subject: [PATCH 20/45] Import IMU chunk metadata in collector view
---
Strand/Screens/RawDataCollectorView.swift | 1 +
1 file changed, 1 insertion(+)
diff --git a/Strand/Screens/RawDataCollectorView.swift b/Strand/Screens/RawDataCollectorView.swift
index f30a10f9ad..17dfad56f1 100644
--- a/Strand/Screens/RawDataCollectorView.swift
+++ b/Strand/Screens/RawDataCollectorView.swift
@@ -1,5 +1,6 @@
import SwiftUI
import StrandDesign
+import WhoopStore
/// iOS/macOS parity twin of Android's 5/MG Raw Data Collector screen.
struct RawDataCollectorView: View {
From 53443a263f74b7d8a8dee0e72b2d6d7aa48478c3 Mon Sep 17 00:00:00 2001
From: Bernd Helm
Date: Wed, 26 Aug 2026 22:56:24 +0200
Subject: [PATCH 21/45] Make IMU session start timestamp nonoptional
---
Strand/Collect/ImuSessionFileStore.swift | 6 +++++-
1 file changed, 5 insertions(+), 1 deletion(-)
diff --git a/Strand/Collect/ImuSessionFileStore.swift b/Strand/Collect/ImuSessionFileStore.swift
index 387dad5385..f2ade04d33 100644
--- a/Strand/Collect/ImuSessionFileStore.swift
+++ b/Strand/Collect/ImuSessionFileStore.swift
@@ -7,7 +7,11 @@ import WhoopProtocol
final class ImuSessionFileStore {
static let shared = ImuSessionFileStore()
- private struct Window: Codable { let id, deviceId: String; var from, to: Int64? }
+ private struct Window: Codable {
+ let id, deviceId: String
+ let from: Int64
+ var to: Int64?
+ }
private let defaults = UserDefaults.standard
private let key = "imu-session-windows-v1"
private let directory: URL
From 011c691b3bba58ef4c730927d193fe182b75293e Mon Sep 17 00:00:00 2001
From: Bernd Helm
Date: Wed, 26 Aug 2026 23:37:19 +0200
Subject: [PATCH 22/45] Batch IMU compression into 30-second blocks
---
Strand/Collect/ImuChunkArchiveStore.swift | 41 +++++----
Strand/Collect/ImuSessionFileStore.swift | 86 ++++++++++++++-----
.../java/com/noop/testcentre/ImuChunkStore.kt | 25 ++++--
.../noop/testcentre/ImuSessionFileStore.kt | 66 ++++++++++----
4 files changed, 154 insertions(+), 64 deletions(-)
diff --git a/Strand/Collect/ImuChunkArchiveStore.swift b/Strand/Collect/ImuChunkArchiveStore.swift
index c009cac97d..66beef77d8 100644
--- a/Strand/Collect/ImuChunkArchiveStore.swift
+++ b/Strand/Collect/ImuChunkArchiveStore.swift
@@ -20,6 +20,7 @@ final class ImuChunkArchiveStore {
}
func pin(sessionId: String, deviceId: String, from: Int, to: Int, ble: BLEManager) async -> [ImuChunkMeta] {
+ ImuSessionFileStore.shared.prepareForRead(sessionId)
let existing = await ble.groundTruthImuChunks(from: from, to: to)
.filter { $0.pinnedUntil == Int.max && file($0).isFileURL && FileManager.default.fileExists(atPath: file($0).path) }
if existing.contains(where: { $0.startTs <= from && $0.endTs >= to
@@ -76,29 +77,37 @@ final class ImuChunkArchiveStore {
guard let data = try? Data(contentsOf: url) else { return [] }
let bytes = [UInt8](data); var offset = 0
var rows: [(Int, [Int16])] = []
- while offset + 24 <= bytes.count {
- let headerTs = Int64(bigEndianBytes: bytes, at: offset); offset += 16 // strap + receipt timestamps
- let length = Int(bytes[offset]) << 24 | Int(bytes[offset + 1]) << 16
- | Int(bytes[offset + 2]) << 8 | Int(bytes[offset + 3])
- offset += 4
- let compressedLength = Int(bytes[offset]) << 24 | Int(bytes[offset + 1]) << 16
- | Int(bytes[offset + 2]) << 8 | Int(bytes[offset + 3]); offset += 4
- guard length > 0, length <= 1_048_576, compressedLength > 0,
- compressedLength <= 1_048_576, offset + compressedLength <= bytes.count else { break }
+ while offset + 12 <= bytes.count {
+ let count = int32(bytes, offset); let rawLength = int32(bytes, offset + 4)
+ let compressedLength = int32(bytes, offset + 8); offset += 12
+ guard count > 0, count <= 30, rawLength > 0, compressedLength > 0,
+ offset + compressedLength <= bytes.count else { break }
let compressed = Data(bytes[offset..= from, ts <= to,
- let cols = Whoop5RawImu.rawColumns(frame) else { continue }
- rows.append((ts, cols))
+ guard written == rawLength else { continue }
+ let raw = [UInt8](decoded); var rawOffset = 0
+ for _ in 0.. 0, rawOffset + length <= raw.count else { break }
+ let frame = Array(raw[rawOffset..= from, ts <= to,
+ let cols = Whoop5RawImu.rawColumns(frame) else { continue }
+ rows.append((ts, cols))
+ }
}
return rows
}
+
+ private func int32(_ bytes: [UInt8], _ offset: Int) -> Int {
+ Int(bytes[offset]) << 24 | Int(bytes[offset + 1]) << 16
+ | Int(bytes[offset + 2]) << 8 | Int(bytes[offset + 3])
+ }
}
private extension Int64 {
diff --git a/Strand/Collect/ImuSessionFileStore.swift b/Strand/Collect/ImuSessionFileStore.swift
index f2ade04d33..20b870fc55 100644
--- a/Strand/Collect/ImuSessionFileStore.swift
+++ b/Strand/Collect/ImuSessionFileStore.swift
@@ -2,7 +2,7 @@ import Compression
import Foundation
import WhoopProtocol
-/// Persisted routing table plus append-only, independently compressed IMU session streams.
+/// Persisted routing table plus append-only, 30-frame compressed IMU session blocks.
@MainActor
final class ImuSessionFileStore {
static let shared = ImuSessionFileStore()
@@ -12,10 +12,13 @@ final class ImuSessionFileStore {
let from: Int64
var to: Int64?
}
+ private struct Record { let ts, receivedAtMs: Int64; let frame: [UInt8] }
+ private static let blockFrames = 30
private let defaults = UserDefaults.standard
private let key = "imu-session-windows-v1"
private let directory: URL
private var seen: [String: Set] = [:]
+ private var pending: [String: [Record]] = [:]
private init() {
let fm = FileManager.default
@@ -31,6 +34,7 @@ final class ImuSessionFileStore {
}
func complete(id: String, toMs: Int64) {
+ flush(id)
var value = windows()
if let index = value.firstIndex(where: { $0.id == id }) { value[index].to = toMs / 1_000; save(value) }
}
@@ -40,7 +44,13 @@ final class ImuSessionFileStore {
value.append(Window(id: id, deviceId: deviceId, from: fromMs / 1_000, to: toMs / 1_000)); save(value)
}
- func remove(id: String) { save(windows().filter { $0.id != id }); seen[id] = nil }
+ func remove(id: String) {
+ pending[id] = nil
+ save(windows().filter { $0.id != id })
+ seen[id] = nil
+ }
+
+ func prepareForRead(_ id: String) { flush(id) }
@discardableResult
func append(deviceId: String, frame: [UInt8], receivedAtMs: Int64) -> Int {
@@ -49,23 +59,12 @@ final class ImuSessionFileStore {
for window in windows() where window.deviceId == deviceId && Int64(ts) >= window.from
&& (window.to == nil || Int64(ts) <= window.to!) {
var timestamps = seen[window.id] ?? scan(window.id)
- guard timestamps.insert(Int64(ts)).inserted,
- let compressed = Self.deflate(Data(frame)) else { continue }
- var record = Data()
- record.appendBigEndian(Int64(ts)); record.appendBigEndian(receivedAtMs)
- record.appendBigEndian(Int32(frame.count)); record.appendBigEndian(Int32(compressed.count))
- record.append(compressed)
- let url = file(window.id)
- if !FileManager.default.fileExists(atPath: url.path) { FileManager.default.createFile(atPath: url.path, contents: nil) }
- guard let handle = try? FileHandle(forWritingTo: url) else { continue }
- do {
- try handle.seekToEnd()
- try handle.write(contentsOf: record)
- try handle.close()
- timestamps.insert(Int64(ts))
- seen[window.id] = timestamps
- count += 1
- } catch { try? handle.close() }
+ guard timestamps.insert(Int64(ts)).inserted else { continue }
+ seen[window.id] = timestamps
+ pending[window.id, default: []].append(Record(ts: Int64(ts), receivedAtMs: receivedAtMs,
+ frame: frame))
+ if pending[window.id]!.count >= Self.blockFrames { flush(window.id) }
+ count += 1
}
return count
}
@@ -81,14 +80,45 @@ final class ImuSessionFileStore {
private func scan(_ id: String) -> Set {
guard let data = try? Data(contentsOf: file(id)) else { return [] }
var result = Set(), offset = 0
- while offset + 24 <= data.count {
- let ts: Int64 = data.bigEndian(at: offset); let compressed: Int32 = data.bigEndian(at: offset + 20)
- guard compressed >= 0, offset + 24 + Int(compressed) <= data.count else { break }
- result.insert(ts); offset += 24 + Int(compressed)
+ while offset + 12 <= data.count {
+ let count: Int32 = data.bigEndian(at: offset)
+ let rawSize: Int32 = data.bigEndian(at: offset + 4)
+ let compressedSize: Int32 = data.bigEndian(at: offset + 8)
+ offset += 12
+ guard count > 0, count <= Self.blockFrames, rawSize > 0, compressedSize > 0,
+ offset + Int(compressedSize) <= data.count,
+ let raw = Self.inflate(data[offset..<(offset + Int(compressedSize))], size: Int(rawSize)) else { break }
+ offset += Int(compressedSize)
+ var rawOffset = 0
+ for _ in 0.. 0, rawOffset + 20 + Int(length) <= raw.count else { break }
+ result.insert(ts); rawOffset += 20 + Int(length)
+ }
}
return result
}
+ private func flush(_ id: String) {
+ guard let records = pending.removeValue(forKey: id), !records.isEmpty else { return }
+ var raw = Data()
+ for record in records {
+ raw.appendBigEndian(record.ts); raw.appendBigEndian(record.receivedAtMs)
+ raw.appendBigEndian(Int32(record.frame.count)); raw.append(contentsOf: record.frame)
+ }
+ guard let compressed = Self.deflate(raw) else { pending[id] = records; return }
+ var block = Data()
+ block.appendBigEndian(Int32(records.count)); block.appendBigEndian(Int32(raw.count))
+ block.appendBigEndian(Int32(compressed.count)); block.append(compressed)
+ let url = file(id)
+ if !FileManager.default.fileExists(atPath: url.path) { FileManager.default.createFile(atPath: url.path, contents: nil) }
+ guard let handle = try? FileHandle(forWritingTo: url) else { pending[id] = records; return }
+ do {
+ try handle.seekToEnd(); try handle.write(contentsOf: block); try handle.close()
+ } catch { try? handle.close(); pending[id] = records }
+ }
+
private static func deflate(_ input: Data) -> Data? {
let capacity = max(input.count + 128, 256); var output = Data(count: capacity)
let written = output.withUnsafeMutableBytes { dst in input.withUnsafeBytes { src in
@@ -98,6 +128,16 @@ final class ImuSessionFileStore {
}}
guard written > 0 else { return nil }; output.count = written; return output
}
+
+ private static func inflate(_ input: Data.SubSequence, size: Int) -> Data? {
+ var output = Data(count: size)
+ let written = output.withUnsafeMutableBytes { dst in input.withUnsafeBytes { src in
+ compression_decode_buffer(dst.bindMemory(to: UInt8.self).baseAddress!, size,
+ src.bindMemory(to: UInt8.self).baseAddress!, input.count,
+ nil, COMPRESSION_ZLIB)
+ }}
+ return written == size ? output : nil
+ }
}
private extension Data {
diff --git a/android/app/src/main/java/com/noop/testcentre/ImuChunkStore.kt b/android/app/src/main/java/com/noop/testcentre/ImuChunkStore.kt
index 7ab3583bb3..2e96488a35 100644
--- a/android/app/src/main/java/com/noop/testcentre/ImuChunkStore.kt
+++ b/android/app/src/main/java/com/noop/testcentre/ImuChunkStore.kt
@@ -24,6 +24,7 @@ class ImuChunkStore(private val context: Context, private val repository: WhoopR
suspend fun pin(sessionId: String, deviceId: String, from: Long, to: Long): List {
require(from <= to)
+ ImuSessionFileStore(context).prepareForRead(sessionId)
val existing = repository.imuChunks(deviceId, from, to)
.filter { it.pinnedUntil == Long.MAX_VALUE && File(context.filesDir, it.relativePath).isFile }
if (existing.any { it.startTs <= from && it.endTs >= to &&
@@ -87,17 +88,21 @@ class ImuChunkStore(private val context: Context, private val repository: WhoopR
DataInputStream(source.inputStream().buffered()).use { input ->
while (true) {
try {
- val ts = input.readLong(); input.readLong()
- val length = input.readInt(); val compressedLength = input.readInt()
- if (length <= 0 || length > MAX_FRAME_BYTES || compressedLength <= 0 || compressedLength > MAX_FRAME_BYTES) break
+ val count = input.readInt(); val rawSize = input.readInt(); val compressedLength = input.readInt()
+ if (count !in 1..BLOCK_FRAMES || rawSize !in 1..MAX_BLOCK_BYTES ||
+ compressedLength !in 1..MAX_BLOCK_BYTES) break
val compressed = ByteArray(compressedLength); input.readFully(compressed)
val inflater = Inflater(); inflater.setInput(compressed)
- val frame = ByteArray(length); val inflated = inflater.inflate(frame); inflater.end()
- if (inflated != length) continue
- val decoded = Whoop5RawImu.decode(frame) ?: continue
- if (ts == decoded.baseTs && ts in from..to) {
- Whoop5RawImu.rawColumns(frame)?.let { rows += ts to it }
- }
+ val raw = ByteArray(rawSize); val inflated = inflater.inflate(raw); inflater.end()
+ if (inflated != rawSize) continue
+ DataInputStream(raw.inputStream()).use { block -> for (index in 0 until count) {
+ val ts = block.readLong(); block.readLong(); val length = block.readInt()
+ if (length !in 1..MAX_FRAME_BYTES) break
+ val frame = ByteArray(length); block.readFully(frame)
+ val decoded = Whoop5RawImu.decode(frame) ?: continue
+ if (ts == decoded.baseTs && ts in from..to)
+ Whoop5RawImu.rawColumns(frame)?.let { rows += ts to it }
+ }}
} catch (_: EOFException) { break }
}
}
@@ -122,5 +127,7 @@ class ImuChunkStore(private val context: Context, private val repository: WhoopR
const val SAMPLE_RATE = 100
const val MAX_EXPORT_SECONDS = 7 * 24 * 60 * 60L
const val MAX_FRAME_BYTES = 1024 * 1024
+ const val BLOCK_FRAMES = 30
+ const val MAX_BLOCK_BYTES = BLOCK_FRAMES * (MAX_FRAME_BYTES + 20)
}
}
diff --git a/android/app/src/main/java/com/noop/testcentre/ImuSessionFileStore.kt b/android/app/src/main/java/com/noop/testcentre/ImuSessionFileStore.kt
index da094b1d5b..f8312c0c97 100644
--- a/android/app/src/main/java/com/noop/testcentre/ImuSessionFileStore.kt
+++ b/android/app/src/main/java/com/noop/testcentre/ImuSessionFileStore.kt
@@ -7,6 +7,7 @@ import java.io.DataOutputStream
import java.io.File
import java.io.FileOutputStream
import java.util.zip.Deflater
+import java.util.zip.Inflater
/** Routes realtime and delayed history IMU frames into persisted session time windows. */
class ImuSessionFileStore(private val context: Context) {
@@ -19,6 +20,7 @@ class ImuSessionFileStore(private val context: Context) {
}
fun complete(id: String, toMs: Long) = synchronized(lock) {
+ flush(id)
if (id in prefs.getStringSet("ids", emptySet()).orEmpty())
prefs.edit().putLong("$id.to", toMs / 1_000L).apply()
}
@@ -28,13 +30,16 @@ class ImuSessionFileStore(private val context: Context) {
}
fun remove(id: String) = synchronized(lock) {
+ pending.remove(id)
prefs.edit().putStringSet("ids", prefs.getStringSet("ids", emptySet()).orEmpty() - id)
.remove("$id.device").remove("$id.from").remove("$id.to").apply()
}
+ fun prepareForRead(id: String) = synchronized(lock) { flush(id) }
+
/**
- * Append one independently-deflated wire frame. The timestamp header makes the stream scanable and
- * idempotent without inflating older records; delayed history may therefore fill gaps in any order.
+ * Queue a wire frame for a 30-second compressed block. Delayed history may append older blocks later;
+ * strap timestamps, not file order, are authoritative.
*/
fun append(deviceId: String, frame: ByteArray, receivedAtMs: Long = System.currentTimeMillis()): Int = synchronized(lock) {
val ts = Whoop5RawImu.decode(frame)?.baseTs ?: return 0
@@ -47,15 +52,9 @@ class ImuSessionFileStore(private val context: Context) {
val file = File(directory, "realtime-imu-$id.imus")
val timestamps = timestamps(file)
if (ts in timestamps) continue
- val deflater = Deflater()
- val compressed = ByteArray(frame.size + 64)
- deflater.setInput(frame); deflater.finish()
- val compressedSize = deflater.deflate(compressed); deflater.end()
- DataOutputStream(BufferedOutputStream(FileOutputStream(file, true))).use {
- it.writeLong(ts); it.writeLong(receivedAtMs); it.writeInt(frame.size); it.writeInt(compressedSize)
- it.write(compressed, 0, compressedSize)
- }
timestamps += ts
+ pending.getOrPut(id) { mutableListOf() } += Record(ts, receivedAtMs, frame.copyOf())
+ if (pending[id]!!.size >= BLOCK_FRAMES) flush(id)
writes++
}
return writes
@@ -65,19 +64,54 @@ class ImuSessionFileStore(private val context: Context) {
val result = mutableSetOf()
if (!file.exists()) return@getOrPut result
java.io.DataInputStream(file.inputStream().buffered()).use { input ->
- while (input.available() >= 24) {
- val ts = input.readLong(); input.readLong(); input.readInt()
- val compressed = input.readInt()
- if (compressed < 0 || compressed > 1_048_576 || input.available() < compressed) break
- result += ts
- input.skipBytes(compressed)
+ while (input.available() >= 12) {
+ val count = input.readInt(); val rawSize = input.readInt(); val compressedSize = input.readInt()
+ if (count !in 1..BLOCK_FRAMES || rawSize !in 1..MAX_BLOCK_BYTES ||
+ compressedSize !in 1..MAX_BLOCK_BYTES || input.available() < compressedSize) break
+ val compressed = ByteArray(compressedSize); input.readFully(compressed)
+ val raw = inflate(compressed, rawSize) ?: break
+ java.io.DataInputStream(raw.inputStream()).use { block ->
+ for (index in 0 until count) {
+ result += block.readLong(); block.readLong()
+ val length = block.readInt(); if (length !in 1..MAX_FRAME_BYTES) break
+ block.skipBytes(length)
+ }
+ }
}
}
result
}
+ private fun flush(id: String) {
+ val records = pending.remove(id).orEmpty()
+ if (records.isEmpty()) return
+ val rawBytes = java.io.ByteArrayOutputStream()
+ DataOutputStream(rawBytes).use { out -> records.forEach { record ->
+ out.writeLong(record.ts); out.writeLong(record.receivedAtMs)
+ out.writeInt(record.frame.size); out.write(record.frame)
+ }}
+ val raw = rawBytes.toByteArray()
+ val deflater = Deflater(); deflater.setInput(raw); deflater.finish()
+ val compressed = ByteArray(raw.size + 128); val size = deflater.deflate(compressed); deflater.end()
+ DataOutputStream(BufferedOutputStream(FileOutputStream(File(directory, "realtime-imu-$id.imus"), true))).use {
+ it.writeInt(records.size); it.writeInt(raw.size); it.writeInt(size); it.write(compressed, 0, size)
+ }
+ }
+
+ private fun inflate(compressed: ByteArray, size: Int): ByteArray? {
+ val inflater = Inflater(); inflater.setInput(compressed)
+ val raw = ByteArray(size); val written = runCatching { inflater.inflate(raw) }.getOrDefault(0); inflater.end()
+ return raw.takeIf { written == size }
+ }
+
+ private data class Record(val ts: Long, val receivedAtMs: Long, val frame: ByteArray)
+
companion object {
private val lock = Any()
private val seen = mutableMapOf>()
+ private val pending = mutableMapOf>()
+ private const val BLOCK_FRAMES = 30
+ private const val MAX_FRAME_BYTES = 1_048_576
+ private const val MAX_BLOCK_BYTES = BLOCK_FRAMES * (MAX_FRAME_BYTES + 20)
}
}
From 0b7b3d64ed5007da8e5e9e6dc584197a5e6aa012 Mon Sep 17 00:00:00 2001
From: Bernd Helm
Date: Thu, 27 Aug 2026 00:23:14 +0200
Subject: [PATCH 23/45] Keep recent-period exclusion in step capture fork
---
Strand/Screens/RawDataCollectorView.swift | 11 +--------
.../com/noop/ui/GroundTruthCollectorScreen.kt | 24 -------------------
.../app/src/main/res/values-de/strings.xml | 4 +---
android/app/src/main/res/values/strings.xml | 4 +---
4 files changed, 3 insertions(+), 40 deletions(-)
diff --git a/Strand/Screens/RawDataCollectorView.swift b/Strand/Screens/RawDataCollectorView.swift
index 17dfad56f1..fd037e2d37 100644
--- a/Strand/Screens/RawDataCollectorView.swift
+++ b/Strand/Screens/RawDataCollectorView.swift
@@ -9,7 +9,6 @@ struct RawDataCollectorView: View {
@StateObject private var store = RawDataSessionStore()
private let archive = ImuChunkArchiveStore()
- @State private var excludeMinutes = "5"
@State private var exportingId: String?
@State private var deleteCandidate: RawDataSessionStore.Session?
@State private var confirmDeleteAll = false
@@ -126,14 +125,6 @@ struct RawDataCollectorView: View {
NoopButton("Stop session", systemImage: "stop.fill", kind: .destructive,
fullWidth: true) { Task { await stop() } }
}
- HStack {
- TextField("Minutes", text: $excludeMinutes)
- .textFieldStyle(.roundedBorder)
- NoopButton("Ignore recent period", kind: .secondary, fullWidth: true) {
- if let minutes = Int(excludeMinutes) { store.excludeLast(minutes: minutes) }
- }
- .disabled(!(1...240).contains(Int(excludeMinutes) ?? 0))
- }
} else {
NoopButton("Start raw-data session", systemImage: "record.circle", kind: .primary,
fullWidth: true) { start() }
@@ -190,7 +181,7 @@ struct RawDataCollectorView: View {
from: Date(timeIntervalSince1970: Double(session.startedAtMs) / 1_000), to: $0) }
))
}
- Text("\(session.steps) steps · \(session.stairs) stairs · \(session.excludedWindows) excluded periods")
+ Text("\(session.steps) steps · \(session.stairs) stairs")
.font(StrandFont.caption).foregroundStyle(StrandPalette.textSecondary)
Text(session.active ? "Export status: recording"
: "Export status: \(rawBatchCounts[session.id, default: 0]) raw batches")
diff --git a/android/app/src/main/java/com/noop/ui/GroundTruthCollectorScreen.kt b/android/app/src/main/java/com/noop/ui/GroundTruthCollectorScreen.kt
index 091f78db19..9df4998215 100644
--- a/android/app/src/main/java/com/noop/ui/GroundTruthCollectorScreen.kt
+++ b/android/app/src/main/java/com/noop/ui/GroundTruthCollectorScreen.kt
@@ -36,8 +36,6 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
-import androidx.compose.foundation.text.KeyboardOptions
-import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.noop.R
@@ -62,7 +60,6 @@ fun GroundTruthCollectorScreen(vm: AppViewModel) {
var state by remember { mutableStateOf(collector.snapshot()) }
var haptics by remember { mutableStateOf(false) }
var exportingSessionId by remember { mutableStateOf(null) }
- var excludeMinutes by remember { mutableStateOf("5") }
var sessions by remember { mutableStateOf(collector.sessions()) }
var latestSensorTs by remember { mutableStateOf(null) }
var sessionPendingDelete by remember { mutableStateOf(null) }
@@ -206,26 +203,6 @@ fun GroundTruthCollectorScreen(vm: AppViewModel) {
},
)
}
- Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {
- OutlinedTextField(
- value = excludeMinutes,
- onValueChange = { value -> excludeMinutes = value.filter(Char::isDigit).take(3) },
- label = { Text(stringResource(R.string.ground_truth_minutes)) },
- keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
- singleLine = true,
- modifier = Modifier.weight(1f),
- )
- NoopButton(
- text = stringResource(R.string.ground_truth_exclude_recent),
- kind = NoopButtonKind.Secondary,
- modifier = Modifier.weight(2f),
- enabled = excludeMinutes.toIntOrNull() in 1..240,
- onClick = {
- excludeMinutes.toIntOrNull()?.let { collector.excludeLastMinutes(it) }
- sessions = collector.sessions()
- },
- )
- }
} else {
NoopButton(
text = stringResource(R.string.ground_truth_start),
@@ -403,7 +380,6 @@ private fun SessionCard(
R.string.ground_truth_session_summary,
session.steps,
session.stairs,
- session.excludedWindows,
),
style = NoopType.caption,
color = Palette.textSecondary,
diff --git a/android/app/src/main/res/values-de/strings.xml b/android/app/src/main/res/values-de/strings.xml
index 486dca0ae3..2cd61e4fcc 100644
--- a/android/app/src/main/res/values-de/strings.xml
+++ b/android/app/src/main/res/values-de/strings.xml
@@ -2487,11 +2487,9 @@
Session exportieren
Export wird erstellt…
Session konnte nicht exportiert werden: %1$s
- Minuten
- Letzten Zeitraum ignorieren
Aufgezeichnete Sessions
Noch keine Sessions aufgezeichnet.
- %1$d Schritte · %2$d Stufen · %3$d ausgeschlossene Zeiträume
+ %1$d Schritte · %2$d Stufen
Kommentar zur Session
Session löschen
Diese Session löschen?
diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml
index 356287064c..400108be05 100644
--- a/android/app/src/main/res/values/strings.xml
+++ b/android/app/src/main/res/values/strings.xml
@@ -2499,11 +2499,9 @@
Export session
Building export…
Couldn’t export session: %1$s
- Minutes
- Ignore recent period
Recorded sessions
No sessions recorded yet.
- %1$d steps · %2$d stairs · %3$d excluded periods
+ %1$d steps · %2$d stairs
Session comment
Delete session
Delete this session?
From e396de8c8e92e65d466730b6ab7920d7d6da9b48 Mon Sep 17 00:00:00 2001
From: Bernd Helm
Date: Thu, 27 Aug 2026 00:30:39 +0200
Subject: [PATCH 24/45] Document verified 5MG raw IMU capture
---
README.md | 1 +
docs/BLE_REVERSE_ENGINEERING.md | 22 +++--
docs/DATA_MODEL.md | 25 ++++++
docs/PROTOCOL.md | 6 ++
docs/RAW_DATA_CAPTURE.md | 137 ++++++++++++++++++++++++++++++++
docs/WHOOP5_DEEP_DATA.md | 20 ++++-
6 files changed, 202 insertions(+), 9 deletions(-)
create mode 100644 docs/RAW_DATA_CAPTURE.md
diff --git a/README.md b/README.md
index 4226c6019a..59d1ea2701 100644
--- a/README.md
+++ b/README.md
@@ -30,6 +30,7 @@
👽 Reddit ·
Features ·
Protocol ·
+ Raw data capture ·
diff --git a/docs/BLE_REVERSE_ENGINEERING.md b/docs/BLE_REVERSE_ENGINEERING.md
index bf7cb94ac6..289e5b71a4 100644
--- a/docs/BLE_REVERSE_ENGINEERING.md
+++ b/docs/BLE_REVERSE_ENGINEERING.md
@@ -312,9 +312,17 @@ stream off, NOOP's primary metric source becomes the **historical offload** (nex
### On-demand raw capture
For research, raw IMU can be captured for a bounded window with `captureRawAccel(seconds:)`, which
-sends `START_RAW_DATA` (81) + `TOGGLE_IMU_MODE` (106), records for the window, then re-issues
-`STOP_RAW_DATA` and disables the stream again. This is opt-in only; the global research toggle
-(`enableRawCapture`) defaults **off** and the app is decoded-only otherwise.
+sends `START_RAW_DATA` (81) followed by `TOGGLE_IMU_MODE` (106), records for the window, then
+re-issues `STOP_RAW_DATA` and disables the stream again. This ordering is hardware-verified on a
+WHOOP 5/MG: opcode 106 alone returns an acknowledgement but does **not** start the producer. The 5/MG
+selector is two bytes (`[0x01, 0x01]` on, `[0x01, 0x00]` off); retaining the older one-byte form here
+was why an apparently successful capture contained no realtime IMU packets.
+
+The manually stopped Raw Data Collector uses the same sequence and accepts delayed historical IMU
+buffers into the session by strap timestamp, so a later offload can repair a Bluetooth gap. This is
+opt-in and bounded; the global research toggle (`enableRawCapture`) defaults **off** and the app is
+decoded-only otherwise. Storage/export details and consumer ordering rules are in
+[5/MG raw data capture](RAW_DATA_CAPTURE.md).
---
@@ -930,9 +938,11 @@ anything, it plausibly gates *those streams* rather than the offload.
Our exposure to that is limited but not zero, and worth stating precisely. NOOP takes live HR from the
standard `0x2A37` profile and disables the R10/R11 flood on connect (§4), and it never sends
`TOGGLE_OPTICAL_MODE` (108) at all. But `captureRawAccel` **does** send `START_RAW_DATA` (81) +
-`TOGGLE_IMU_MODE` (106) — on demand, for a bounded window, never continuously. So the one place this
-could bite is an on-demand raw-accel capture on a 5/MG. Whether that path yields IMU frames today is
-the observation that would settle the narrow claim, and it is not recorded anywhere here.
+`TOGGLE_IMU_MODE` (106) — on demand, for a bounded window, never continuously. That path is now
+hardware-verified to yield decoded 100 Hz six-axis IMU after the two-byte 5/MG selector is used.
+It works without implementing a `LINK_VALID` response, so the proposed handshake is not required for
+this raw-IMU producer on the tested strap/firmware. This does not prove that every firmware treats
+`LINK_VALID` identically or that ignoring it can never affect link stability.
**Two questions are genuinely open**, and a capture answers both without implementing anything:
diff --git a/docs/DATA_MODEL.md b/docs/DATA_MODEL.md
index 0306fffdab..18a08e9fbf 100644
--- a/docs/DATA_MODEL.md
+++ b/docs/DATA_MODEL.md
@@ -297,6 +297,31 @@ inserts, identical range-read shape).
**Primary key:** `(deviceId, ts)`.
+This is the low-rate decoded vector used by normal analytics, not the 100 Hz six-axis capture. High-rate
+IMU payloads deliberately remain outside the per-sample SQLite schema: SQLite stores only searchable
+`imuChunk` metadata, while compressed `.imus`/`.imuc` files hold the original frames and materialised
+sample columns. See [5/MG raw data capture](RAW_DATA_CAPTURE.md#storage-design).
+
+#### `imuChunk` — file-backed high-rate IMU catalog
+
+| Column | Type | Notes |
+| --- | --- | --- |
+| `id` | TEXT NOT NULL | Primary key and `.imuc` filename stem. |
+| `deviceId` | TEXT NOT NULL | Owning strap. |
+| `startTs` / `endTs` | INTEGER NOT NULL | Inclusive strap Unix-second coverage. |
+| `sampleCount` | INTEGER NOT NULL | Decoded axis-sample count represented by the chunk. |
+| `sampleRate` | INTEGER NOT NULL | Currently 100 Hz. |
+| `formatVersion` | INTEGER NOT NULL | `NOOPIMU` container version. |
+| `codec` | TEXT NOT NULL | Currently `zip-deflate`. |
+| `relativePath` | TEXT NOT NULL | App-private path to the payload outside SQLite. |
+| `byteSize` | INTEGER NOT NULL | Stored file length. |
+| `sha256` | TEXT NOT NULL | Payload integrity/provenance hash. |
+| `createdAt` | INTEGER NOT NULL | Unix seconds. |
+| `pinnedUntil` | INTEGER | Retention marker; permanent session chunks use the platform maximum. |
+
+The index is `(deviceId, startTs, endTs)`. A catalog row without its file is not coverage; readers
+always verify that `relativePath` exists.
+
---
## Raw outbox (transient, prunable)
diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md
index 2927329ba2..c06cc4f5ef 100644
--- a/docs/PROTOCOL.md
+++ b/docs/PROTOCOL.md
@@ -424,6 +424,12 @@ public func frame(seq: UInt8, payload: [UInt8] = [0x00]) -> [UInt8] {
| 122 | `STOP_HAPTICS` | `[0x00]` | stop an in-progress haptic |
| 123 | `SELECT_WRIST` | — | set strap wrist |
+**5/MG raw-IMU sequence (hardware-verified):** command 106 accepting a write does not mean that the
+producer started. A bounded capture first sends `START_RAW_DATA` (81) `[0x01]`, then command 106 with
+the two-byte selector `[0x01, 0x01]`. Stop uses `STOP_RAW_DATA` (82) `[0x01]`, then command 106
+`[0x01, 0x00]`. The one-byte payload in the table remains the WHOOP 4 form. See
+[5/MG raw data capture](RAW_DATA_CAPTURE.md) for storage, history repair, and export semantics.
+
**Payload builders** in `WhoopCommand`:
- `setAlarmPayload(epochSec:)` → `[0x01] + epoch u32 LE + [0x00, 0x00]` (7 bytes).
diff --git a/docs/RAW_DATA_CAPTURE.md b/docs/RAW_DATA_CAPTURE.md
new file mode 100644
index 0000000000..de9d2314d8
--- /dev/null
+++ b/docs/RAW_DATA_CAPTURE.md
@@ -0,0 +1,137 @@
+# 5/MG raw data capture
+
+**Status:** experimental, user-initiated, hardware-verified on WHOOP 5/MG. The capture path is a
+research/export facility; it does not feed recovery, sleep, steps, or another production score.
+
+## What it is for
+
+The Raw Data Collector records a bounded interval of the strap's high-rate six-axis motion data and
+exports it with enough provenance to analyse later. Typical uses are protocol validation, labelled
+activity datasets, offline algorithm development, and checking whether a Bluetooth interruption was
+repaired by a later history sync.
+
+It lives in **Test Centre → 5/MG Raw Data Collector** on Android and Apple platforms. A session may be
+started and stopped live, or created afterwards for an existing time range. Start/end times of a
+completed session can be edited, individual sessions can be deleted, and all completed sessions can
+be deleted after confirmation. The generic collector does not assume a particular labelling task;
+manual step/stair controls are optional fork extensions rather than a prerequisite for capture.
+
+## What was verified on hardware
+
+On WHOOP 5/MG, command 106 by itself can return `SUCCESS` without starting any IMU producer. The
+working bounded sequence is:
+
+1. `START_RAW_DATA` (81), payload `[0x01]`;
+2. `TOGGLE_IMU_MODE` (106), payload `[0x01, 0x01]`;
+3. receive and decode the 100 Hz six-axis buffers;
+4. on stop, send `STOP_RAW_DATA` (82), payload `[0x01]`, then `TOGGLE_IMU_MODE` with
+ `[0x01, 0x00]`.
+
+The writes use the authenticated WHOOP command characteristic and require a connected/bonded strap.
+An accepted command is not evidence that samples arrived, so the collector reports connection state,
+request state, packet/byte counts, the last packet time, and history-sync progress separately.
+
+The decoded IMU buffer contains 100 signed 16-bit samples for each of `ax, ay, az, gx, gy, gz`, keyed
+by a strap Unix timestamp. Accelerometer scale is `1/4096 g/LSB`; gyroscope scale is
+`0.06104 deg/s/LSB`. See [BLE reverse engineering](BLE_REVERSE_ENGINEERING.md#4-the-realtime-r10r11-raw-stream-type-43)
+for the byte layout and validation evidence.
+
+An Android Bluetooth HCI capture of the official WHOOP app around a Strength Trainer session was the
+useful behavioural reference: high-rate collection was session-oriented rather than a permanently
+enabled background stream. It helped identify what to test, but NOOP's implementation and validation
+remain clean-room and use only independently implemented protocol commands/decoders. The observation
+also does not establish the battery cost of keeping the mode enabled beyond a workout-sized window.
+
+## Live capture and history repair
+
+A live BLE connection is not assumed to be lossless. Each session is a time window tied to one strap,
+and incoming IMU buffers are routed by **strap timestamp**, not by arrival time. If Bluetooth is off or
+the phone is disconnected during part of a recording, matching delayed buffers from a later historical
+offload can still be appended to the session. Duplicate timestamps are discarded.
+
+Consequences for consumers:
+
+- file order is not chronological: repaired older history may be appended after newer live data;
+- strap timestamp is authoritative and readers must sort by it;
+- export metadata reports actual chunk coverage and `imu_100hz_complete`; it must not infer complete
+ capture merely because the user started and stopped a session;
+- history can repair only data the strap actually retained. The design does not promise that every
+ firmware retains every high-rate buffer for later offload.
+
+The historical-range action is therefore useful even when the collector was not running at the time:
+it creates a session window over raw IMU buffers already available locally or delivered by the next
+history sync. A range is currently bounded to seven days to keep an accidental export finite.
+
+## Storage design
+
+High-rate samples do **not** live as one SQLite row per sample. That would add avoidable write
+amplification, database growth, migration burden, and backup cost to data whose main consumer is a
+sequential signal-analysis job.
+
+Instead, storage is split by responsibility:
+
+| Data | Storage | Lifetime |
+|---|---|---|
+| Session window, comments, optional labels | Small app-private JSON/JSONL metadata | Until the user deletes the session |
+| Incoming raw IMU frames | Append-only `.imus` session file | Until materialised/exported or the session is deleted |
+| Searchable chunk metadata | SQLite (`imuChunk`) | Index/provenance only; no individual samples |
+| Immutable analysis/export payload | Compressed `.imuc` file | Pinned to the retained session/export |
+
+The `.imus` writer batches up to 30 one-second frames into an independently zlib-compressed block.
+Blocks are appended; a crash can at worst leave a truncated final block, while earlier blocks remain
+readable. Pending blocks are flushed when a session stops or before it is read. Each record keeps the
+strap timestamp, phone receive time, and original frame bytes. This preserves evidence for a future
+decoder instead of committing early to today's interpretation.
+
+An `.imuc` file is a ZIP/deflate container with:
+
+- `manifest.json`: `NOOPIMU`, format version, 100 Hz sample rate, axes, timestamp source, coverage,
+ ordering contract, and row count;
+- `samples.bin`: repeated big-endian timestamp and byte-length headers followed by column-major,
+ little-endian signed 16-bit axis samples.
+
+The format and archive semantics are mirrored on Android and Apple. SHA-256, byte size, codec,
+coverage, and sample count are stored in `imuChunk`; the payload itself remains outside SQLite.
+
+## Session export
+
+The shareable ZIP contains session provenance and all available sensor material for its selected
+interval. Android currently includes `meta.json`, label/event JSONL and CSV files, decoded one-second
+signals, raw sensor CSV, and `imu/*.imuc`. Apple exports equivalent session metadata/events,
+`history-sensors.csv`, `imu-coverage.json`, and IMU chunks through its platform export path. Inspect
+`meta.json` plus the platform's IMU coverage object first:
+
+- `started_at_ms` / `ended_at_ms` are the selected analysis interval;
+- `captured_started_at_ms` / `captured_ended_at_ms`, when present, preserve the physical recording
+ interval even after the selected interval is edited;
+- Android's `imu_100hz_coverage` identifies the chunks actually present and `imu_100hz_complete` is
+ the conservative coverage result; Apple carries the same facts in `imu-coverage.json`;
+- manual labels, when present, are annotations and never proof of sensor completeness.
+
+Exports stay local until the user invokes the operating system's share sheet. Raw captures are not
+part of routine cloud sync or telemetry, consistent with NOOP's offline-first privacy model.
+
+## Scope and operational limits
+
+- Capture is deliberately bounded and explicit. The current evidence does **not** establish battery,
+ flash-retention, thermal, or BLE-airtime costs for continuous 24/7 100 Hz operation.
+- A one-hour workout/research capture succeeding does not establish that a 36-hour rolling recorder is
+ safe. Any future rolling buffer needs hardware measurements and an explicit retention policy.
+- The older passive “Record 5/MG raw capture” protocol trace and the session collector have different
+ jobs. The former preserves broad offload/debug frames; the latter owns a time-bounded, exportable IMU
+ dataset. They may observe the same wire frame, but the session store deduplicates it by strap time.
+- The former `rawImuSample` per-sample SQLite design was intentionally removed: no production path had
+ written it, and high-rate payloads now use file-backed chunks.
+- Do not use arrival order as time, do not fill gaps silently, and do not claim 100 Hz coverage from
+ packet count alone.
+
+## Implementation map
+
+| Concern | Apple | Android |
+|---|---|---|
+| Collector UI | `Strand/Screens/RawDataCollectorView.swift` | `com.noop.ui.GroundTruthCollectorScreen` |
+| Session metadata | `RawDataSessionStore` | `GroundTruthCollector` |
+| Live command path | `BLEManager.startGroundTruthRawCapture` | `WhoopBleClient.startGroundTruthImuCapture` |
+| Append/recovery window | `ImuSessionFileStore` | `ImuSessionFileStore` |
+| Immutable chunk export | `ImuChunkArchiveStore` | `ImuChunkStore` |
+| Raw decoder | `Whoop5RawImu` in `WhoopProtocol` | `Whoop5RawImu` in `com.noop.protocol` |
diff --git a/docs/WHOOP5_DEEP_DATA.md b/docs/WHOOP5_DEEP_DATA.md
index ade528ec0e..e2f9734d09 100644
--- a/docs/WHOOP5_DEEP_DATA.md
+++ b/docs/WHOOP5_DEEP_DATA.md
@@ -79,6 +79,19 @@ that otherwise reproduced flags 1–15 byte-for-byte in this order.
- **iOS / Android only on real hardware:** macOS CoreBluetooth can't complete the authenticated SMP bond
the command characteristic requires, so the write path is unavailable on Mac.
+## High-rate IMU capture is a separate switch
+
+The R22 feature flags above govern deep-history products; they are not the missing step for an explicit
+100 Hz motion session. Hardware testing found a separate bounded raw-data sequence: `START_RAW_DATA`
+(81) must precede the two-byte 5/MG `TOGGLE_IMU_MODE` (106) selector. Sending 106 alone can return
+`SUCCESS` while producing no packets. With `[0x01, 0x01]` after command 81, NOOP receives and decodes
+the six-axis 100 Hz buffers; stop uses command 82 followed by `[0x01, 0x00]`.
+
+This establishes an on-demand research/workout capture path, not a safe continuous mode. Battery,
+retention, and BLE-airtime cost over day-scale recording remain unmeasured. The session collector,
+file-backed storage, Bluetooth-gap repair, and export contract are documented in
+[5/MG raw data capture](RAW_DATA_CAPTURE.md).
+
## Honest limits
- **No cloud scores.** Recovery/strain/sleep *scores* are computed in WHOOP's cloud and no public
@@ -90,9 +103,10 @@ that otherwise reproduced flags 1–15 byte-for-byte in this order.
`get_data_range`/`send_historical_data` loop NOOP already runs. If it does, the write path is belt-and-
suspenders.
- **The large records are no longer an undifferentiated type-`0x2F` blob.** Layout v21 (1,244 bytes)
- contains six-axis IMU data; layout v20 (2,140 bytes) contains five repeated optical measurement
- blocks; layout v26 contains a 24-sample PPG waveform. The v20 blocks are preserved without
- wavelength labels because the current capture does not prove red/IR identity.
+ contains six-axis IMU data; layout v20 (2,140 bytes) contains five repeated measurement blocks whose
+ sensor identity remains open; layout v26 contains a 24-sample PPG waveform. The v20 blocks are
+ preserved without optical/wavelength labels because the current capture does not prove what produced
+ them, let alone red/IR identity.
- **SpO₂ is not “one calibration away.”** The current v20 corpus has three active measurement blocks,
but it has not established two separate red and infrared illumination measurements. See
[`WHOOP5_OPTICAL_EXPERIMENT.md`](WHOOP5_OPTICAL_EXPERIMENT.md) for the passive controlled experiment
From 1971179c511ecb607d194417ca2597feb45a225b Mon Sep 17 00:00:00 2001
From: Bernd Helm
Date: Thu, 27 Aug 2026 01:31:34 +0200
Subject: [PATCH 25/45] Refine Android raw capture sessions
---
.../Resources/raw_data_collector_parity.json | 4 -
android/app/build.gradle.kts | 2 +-
.../main/java/com/noop/ble/WhoopBleClient.kt | 26 ++++
.../noop/testcentre/GroundTruthCollector.kt | 123 +++++++++++-------
.../noop/testcentre/ImuSessionFileStore.kt | 10 ++
.../com/noop/ui/GroundTruthCollectorScreen.kt | 122 +++++++++++++++--
.../resources/raw_data_collector_parity.json | 4 -
android/gradle.properties | 8 +-
8 files changed, 229 insertions(+), 70 deletions(-)
diff --git a/StrandTests/Resources/raw_data_collector_parity.json b/StrandTests/Resources/raw_data_collector_parity.json
index e3c13ecb28..a7e34bd4ac 100644
--- a/StrandTests/Resources/raw_data_collector_parity.json
+++ b/StrandTests/Resources/raw_data_collector_parity.json
@@ -17,10 +17,6 @@
"swift": ["func undo(", "Undo last click"],
"kotlin": ["fun undo(", "ground_truth_undo"]
},
- "exclude_recent_period": {
- "swift": ["excludeLast(minutes:", "Ignore recent period"],
- "kotlin": ["excludeLastMinutes(", "ground_truth_exclude_recent"]
- },
"session_comments": {
"swift": ["setComment(", "Session comment"],
"kotlin": ["setComment(", "ground_truth_comment"]
diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
index 21857254e3..362d85e4f2 100644
--- a/android/app/build.gradle.kts
+++ b/android/app/build.gradle.kts
@@ -26,7 +26,7 @@ android {
applicationId = "com.noop.whoop"
minSdk = 26
targetSdk = 34
- versionCode = 373
+ versionCode = 375
versionName = "10.6.1"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
diff --git a/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt b/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt
index 0d58f8393c..3eeed76c53 100644
--- a/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt
+++ b/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt
@@ -2184,6 +2184,8 @@ class WhoopBleClient(
@Volatile private var groundTruthImuCommandAllowed = false
private var groundTruthImuSessionId: String? = null
+ private var groundTruthImuStoppedAtMs = 0L
+ private var unexpectedImuStopAtMs = 0L
/** Start the hardware-confirmed WHOOP 5 realtime IMU mode for an explicit ground-truth session. */
@Synchronized
@@ -2203,6 +2205,7 @@ class WhoopBleClient(
return false
}
groundTruthImuSessionId = sessionId
+ groundTruthImuStoppedAtMs = 0L
groundTruthImuCommandAllowed = true
try {
// The verified bounded raw-capture sequence is START_RAW_DATA followed by the
@@ -2231,6 +2234,7 @@ class WhoopBleClient(
}
log("Ground-truth realtime IMU requested OFF (session=${groundTruthImuSessionId ?: "none"})")
groundTruthImuSessionId = null
+ groundTruthImuStoppedAtMs = System.currentTimeMillis()
_groundTruthImuStatus.update { it.copy(requested = false, note = "Stopped; accepting history repair") }
}
@@ -2264,6 +2268,27 @@ class WhoopBleClient(
}
}
+ /** Fail-safe for a producer left armed by a crash, another client, or a lost stop write. */
+ @Synchronized
+ private fun stopUnexpectedRealtimeImu(frame: ByteArray, replayedOffload: Boolean) {
+ if (connectedFamily != DeviceFamily.WHOOP5 || replayedOffload || frame.size <= 8) return
+ val type = frame[8].toInt() and 0xFF
+ if (type != 43 && type != 51) return
+ if (groundTruthImuSessionId != null || PuffinExperiment.from(context).isCaptureEnabled) return
+ val now = System.currentTimeMillis()
+ if (now - groundTruthImuStoppedAtMs < 3_000L || now - unexpectedImuStopAtMs < 30_000L) return
+ if (gatt == null || cmdCharacteristic == null) return
+ unexpectedImuStopAtMs = now
+ groundTruthImuCommandAllowed = true
+ try {
+ send(CommandNumber.STOP_RAW_DATA, byteArrayOf(1), withResponse = true)
+ send(CommandNumber.TOGGLE_IMU_MODE, byteArrayOf(1, 0), withResponse = true)
+ } finally {
+ groundTruthImuCommandAllowed = false
+ }
+ log("Raw IMU fail-safe: unexpected realtime packet type $type while capture was off; stop requested")
+ }
+
/** Frame reassembler for the fragmented custom notify chars (port of Reassembler). Reassigned per
* connection with the detected family — WHOOP5/MG frames use a different length encoding. */
private var reassembler = Reassembler()
@@ -6045,6 +6070,7 @@ class WhoopBleClient(
// handleFrame's replayedOffload gate, so evaluating it twice bounds-checked + indexed
// every offloaded frame for nothing. (The Swift 5/MG inbound loop already hoists this.)
val offloadFrame = backfilling && isOffloadFrame(frame, connectedFamily)
+ stopUnexpectedRealtimeImu(frame, offloadFrame)
noteWhoop5R22Telemetry(frame, offloadFrame) // #174
// #47: decode this frame ONCE and thread it to both consumers (the router below and the
// live collector) instead of each re-parsing it — steady-state drops 2→1 parse per live
diff --git a/android/app/src/main/java/com/noop/testcentre/GroundTruthCollector.kt b/android/app/src/main/java/com/noop/testcentre/GroundTruthCollector.kt
index 05d4132e91..3356ec8ee1 100644
--- a/android/app/src/main/java/com/noop/testcentre/GroundTruthCollector.kt
+++ b/android/app/src/main/java/com/noop/testcentre/GroundTruthCollector.kt
@@ -14,6 +14,7 @@ import java.util.zip.ZipOutputStream
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONObject
+import java.util.UUID
/** App-private, explicitly user-started bounded 5/MG raw-data capture with optional manual labels. */
class GroundTruthCollector private constructor(private val context: Context) {
@@ -50,17 +51,29 @@ class GroundTruthCollector private constructor(private val context: Context) {
val capturedEndedAtMs: Long?,
val steps: Int,
val stairs: Int,
- val excludedWindows: Int,
val comment: String,
val active: Boolean,
val exported: Boolean,
+ val lastExportedAtMs: Long?,
+ val markers: List,
)
+ data class Marker(val id: String, val atMs: Long, val type: String, val text: String)
+ data class CaptureStats(val bytes: Long, val coveredSeconds: Int, val expectedSeconds: Int) {
+ val missingSeconds get() = (expectedSeconds - coveredSeconds).coerceAtLeast(0)
+ val complete get() = expectedSeconds > 0 && missingSeconds == 0
+ }
+
private val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
private val directory = File(context.filesDir, "ground-truth").apply { mkdirs() }
- fun realtimeImuBytes(sessionId: String): Long =
- File(directory, "realtime-imu-$sessionId.imus").takeIf(File::isFile)?.length() ?: 0L
+ fun captureStats(session: SessionSummary, nowMs: Long = System.currentTimeMillis()): CaptureStats {
+ val end = session.endedAtMs ?: nowMs
+ val from = session.startedAtMs / 1_000L
+ val to = end / 1_000L
+ val raw = ImuSessionFileStore(context).stats(session.id, from, to)
+ return CaptureStats(raw.bytes, raw.coveredSeconds, (to - from + 1).coerceAtLeast(0).toInt())
+ }
@Synchronized
fun snapshot(): Snapshot = Snapshot(
@@ -101,19 +114,6 @@ class GroundTruthCollector private constructor(private val context: Context) {
return snapshot()
}
- @Synchronized
- fun excludeLastMinutes(minutes: Int, nowMs: Long = System.currentTimeMillis()): Snapshot {
- require(minutes in 1..240) { "Minutes must be between 1 and 240" }
- val before = snapshot()
- if (!before.active || before.sessionId == null) return before
- val fromMs = maxOf(before.startedAtMs, nowMs - minutes * 60_000L)
- append(before.sessionId, event(nowMs, KIND_EXCLUDE, before.steps, before.stairs, before.lastNoopSteps, null).apply {
- put("from_ms", fromMs)
- put("to_ms", nowMs)
- })
- return snapshot()
- }
-
@Synchronized
fun setComment(sessionId: String, comment: String) {
prefs.edit().putString(sessionCommentKey(sessionId), comment.take(4_000)).apply()
@@ -151,10 +151,14 @@ class GroundTruthCollector private constructor(private val context: Context) {
capturedEndedAtMs = capturedEnd.takeIf { hasCapture },
steps = last.optInt("steps_total"),
stairs = last.optInt("stairs_total"),
- excludedWindows = rows.count { it.optString("kind") == KIND_EXCLUDE },
comment = prefs.getString(sessionCommentKey(id), "").orEmpty(),
active = isCurrent && current.active,
exported = prefs.getBoolean(sessionExportedKey(id), isCurrent && current.exported),
+ lastExportedAtMs = prefs.getLong(sessionLastExportKey(id), 0L).takeIf { it > 0 },
+ markers = rows.filter { it.optString("kind") == KIND_MARKER }.map { row ->
+ Marker(row.optString("marker_id"), row.optLong("at_ms"),
+ row.optString("marker_type", "Moment"), row.optString("text"))
+ }.sortedBy(Marker::atMs),
)
}
.sortedByDescending { it.startedAtMs }
@@ -183,6 +187,7 @@ class GroundTruthCollector private constructor(private val context: Context) {
.remove(sessionEndKey(sessionId))
.remove(sessionCommentKey(sessionId))
.remove(sessionExportedKey(sessionId))
+ .remove(sessionLastExportKey(sessionId))
.remove(sessionUsedStartKey(sessionId))
.remove(sessionUsedEndKey(sessionId))
.remove(sessionCaptureKey(sessionId))
@@ -218,6 +223,49 @@ class GroundTruthCollector private constructor(private val context: Context) {
return sessions().firstOrNull { it.id == id }
}
+ @Synchronized
+ fun addMarker(sessionId: String, atMs: Long, type: String, text: String): Marker? {
+ val session = sessions().firstOrNull { it.id == sessionId } ?: return null
+ val end = session.endedAtMs ?: System.currentTimeMillis()
+ val marker = Marker(UUID.randomUUID().toString(), atMs.coerceIn(session.startedAtMs, end),
+ type.take(40), text.take(500))
+ append(sessionId, JSONObject().apply {
+ put("at_ms", marker.atMs); put("kind", KIND_MARKER); put("marker_id", marker.id)
+ put("marker_type", marker.type); put("text", marker.text)
+ put("steps_total", session.steps); put("stairs_total", session.stairs)
+ })
+ return marker
+ }
+
+ @Synchronized
+ fun updateMarker(sessionId: String, marker: Marker): Boolean {
+ val session = sessions().firstOrNull { it.id == sessionId } ?: return false
+ val end = session.endedAtMs ?: System.currentTimeMillis()
+ return rewriteEvents(sessionId) { rows -> rows.map { row ->
+ if (row.optString("kind") == KIND_MARKER && row.optString("marker_id") == marker.id)
+ JSONObject(row.toString()).apply {
+ put("at_ms", marker.atMs.coerceIn(session.startedAtMs, end))
+ put("marker_type", marker.type.take(40)); put("text", marker.text.take(500))
+ } else row
+ }}
+ }
+
+ @Synchronized
+ fun deleteMarker(sessionId: String, markerId: String): Boolean = rewriteEvents(sessionId) { rows ->
+ rows.filterNot { it.optString("kind") == KIND_MARKER && it.optString("marker_id") == markerId }
+ }
+
+ private fun rewriteEvents(sessionId: String, transform: (List) -> List): Boolean {
+ val file = eventFile(sessionId); if (!file.isFile) return false
+ val rows = file.useLines { it.filter(String::isNotBlank).map(::JSONObject).toList() }
+ val content = transform(rows).joinToString("\n", postfix = "\n") { it.toString() }
+ val staged = File(file.parentFile, ".${file.name}.tmp")
+ return runCatching {
+ staged.writeText(content)
+ check(staged.renameTo(file)) { "Could not commit marker edit" }
+ }.onFailure { staged.delete() }.isSuccess
+ }
+
/** Delete every completed capture. Active captures are deliberately retained. */
@Synchronized
fun deleteAllSessions(): Int {
@@ -296,9 +344,8 @@ class GroundTruthCollector private constructor(private val context: Context) {
.toList()
}
val endMs = summary.endedAtMs ?: events.lastOrNull()?.optLong("at_ms") ?: summary.startedAtMs
- val exclusions = exclusionWindows(events)
// A raw-data session is useful even without manual step labels. Export its complete bounded
- // interval; labels and exclusion windows are optional annotations, not a prerequisite for data.
+ // interval; labels and markers are optional annotations, not a prerequisite for data.
val sensorFrom = if (deviceId == null) 1L else summary.startedAtMs / 1_000L
val sensorTo = if (deviceId == null) 0L else endMs / 1_000L
val pinnedChunks = if (deviceId == null) emptyList() else
@@ -327,7 +374,9 @@ class GroundTruthCollector private constructor(private val context: Context) {
put("manual_steps", summary.steps)
put("manual_stairs", summary.stairs)
put("comment", summary.comment)
- put("excluded_windows", summary.excludedWindows)
+ put("markers", org.json.JSONArray().apply { summary.markers.forEach { marker -> put(JSONObject().apply {
+ put("id", marker.id); put("at_ms", marker.atMs); put("type", marker.type); put("text", marker.text)
+ }) } })
if (id == snap.sessionId && snap.noopStepsAtStart != null) put("noop_steps_at_start", snap.noopStepsAtStart)
if (id == snap.sessionId && snap.lastNoopSteps != null) put("noop_steps_at_end", snap.lastNoopSteps)
put("device_family", "Android")
@@ -345,9 +394,6 @@ class GroundTruthCollector private constructor(private val context: Context) {
out.putNextEntry(ZipEntry("seconds.csv"))
out.writerEntry(secondsCsv(events, sensorFrom * 1_000L, sensorTo * 1_000L))
- out.putNextEntry(ZipEntry("exclusions.csv"))
- out.writerEntry(exclusionsCsv(events))
-
out.putNextEntry(ZipEntry("algorithm-signals.csv"))
out.bufferedWriterNoClose().use { writer ->
writeAlgorithmSignalsCsv(
@@ -356,7 +402,6 @@ class GroundTruthCollector private constructor(private val context: Context) {
deviceId.orEmpty(),
sensorFrom,
sensorTo,
- exclusions,
)
writer.flush()
}
@@ -388,7 +433,8 @@ class GroundTruthCollector private constructor(private val context: Context) {
out.closeEntry()
}
}
- prefs.edit().putBoolean(sessionExportedKey(id), true).apply()
+ val exportedAt = System.currentTimeMillis()
+ prefs.edit().putBoolean(sessionExportedKey(id), true).putLong(sessionLastExportKey(id), exportedAt).apply()
if (imuComplete) {
ImuSessionFileStore(context).remove(id)
File(directory, "realtime-imu-$id.imus").delete()
@@ -444,9 +490,8 @@ class GroundTruthCollector private constructor(private val context: Context) {
}
private fun secondsCsv(events: List, startMs: Long, endMs: Long): String = buildString {
- append("unix_s,manual_steps_delta,manual_stairs_delta,manual_steps_total,manual_stairs_total,noop_steps,excluded\n")
+ append("unix_s,manual_steps_delta,manual_stairs_delta,manual_steps_total,manual_stairs_total,noop_steps\n")
val bySecond = events.groupBy { it.optLong("at_ms") / 1_000L }
- val exclusions = exclusionWindows(events)
var steps = 0; var stairs = 0; var noop: Int? = null
for (second in startMs / 1_000L..endMs / 1_000L) {
val rows = bySecond[second].orEmpty()
@@ -457,24 +502,10 @@ class GroundTruthCollector private constructor(private val context: Context) {
steps = last.optInt("steps_total", steps); stairs = last.optInt("stairs_total", stairs)
if (last.has("noop_steps")) noop = last.optInt("noop_steps")
}
- val excluded = exclusions.any { second * 1_000L <= it.second && second * 1_000L + 999L >= it.first }
- append("$second,$stepDelta,$stairDelta,$steps,$stairs,${noop ?: ""},$excluded\n")
+ append("$second,$stepDelta,$stairDelta,$steps,$stairs,${noop ?: ""}\n")
}
}
- private fun exclusionsCsv(events: List): String = buildString {
- append("from_ms,to_ms,from_unix_s,to_unix_s\n")
- for ((from, to) in exclusionWindows(events)) append("$from,$to,${from / 1_000L},${to / 1_000L}\n")
- }
-
- private fun exclusionWindows(events: List): List> = events
- .filter { it.optString("kind") == KIND_EXCLUDE }
- .mapNotNull { row ->
- val from = row.optLong("from_ms", -1L)
- val to = row.optLong("to_ms", -1L)
- if (from >= 0L && to >= from) from to to else null
- }
-
private fun csv(value: String): String = if (value.any { it == ',' || it == '"' || it == '\n' })
"\"${value.replace("\"", "\"\"")}\"" else value
@@ -484,9 +515,8 @@ class GroundTruthCollector private constructor(private val context: Context) {
deviceId: String,
from: Long,
to: Long,
- exclusions: List>,
) {
- out.write("unix_s,counter,activity_class,gravity_x,gravity_y,gravity_z,dynamic_accel,cadence,heart_rate,band_sleep_state,excluded\n")
+ out.write("unix_s,counter,activity_class,gravity_x,gravity_y,gravity_z,dynamic_accel,cadence,heart_rate,band_sleep_state\n")
if (from > to || deviceId.isBlank()) return
val seconds = (to - from + 1L).coerceIn(1L, 86_400L).toInt()
val steps = repo.stepSamples(deviceId, from, to, seconds).associateBy { it.ts }
@@ -498,7 +528,6 @@ class GroundTruthCollector private constructor(private val context: Context) {
for (ts in timestamps) {
val step = steps[ts]
val grav = gravity[ts]
- val excluded = exclusions.any { ts * 1_000L <= it.second && ts * 1_000L + 999L >= it.first }
out.write(listOf(
ts,
step?.counter ?: "",
@@ -510,7 +539,6 @@ class GroundTruthCollector private constructor(private val context: Context) {
cadence[ts] ?: "",
heartRate[ts] ?: "",
sleepState[ts] ?: "",
- excluded,
).joinToString(","))
out.write("\n")
}
@@ -576,6 +604,7 @@ class GroundTruthCollector private constructor(private val context: Context) {
private fun sessionEndKey(id: String) = "session.$id.endedAtMs"
private fun sessionCommentKey(id: String) = "session.$id.comment"
private fun sessionExportedKey(id: String) = "session.$id.exported"
+ private fun sessionLastExportKey(id: String) = "session.$id.lastExportedAtMs"
private fun sessionUsedStartKey(id: String) = "session.$id.usedStartedAtMs"
private fun sessionUsedEndKey(id: String) = "session.$id.usedEndedAtMs"
private fun sessionCaptureKey(id: String) = "session.$id.hasRealtimeCapture"
@@ -595,7 +624,7 @@ class GroundTruthCollector private constructor(private val context: Context) {
private const val KIND_KEY = "key"
private const val KIND_UNDO_STEP = "undo_step"
private const val KIND_UNDO_STAIR = "undo_stair"
- private const val KIND_EXCLUDE = "exclude_window"
+ private const val KIND_MARKER = "marker"
private const val PREFS = "noop_ground_truth_collector"
private const val MAX_RANGE_MS = 7L * 24 * 60 * 60 * 1_000
diff --git a/android/app/src/main/java/com/noop/testcentre/ImuSessionFileStore.kt b/android/app/src/main/java/com/noop/testcentre/ImuSessionFileStore.kt
index f8312c0c97..f77127f7a7 100644
--- a/android/app/src/main/java/com/noop/testcentre/ImuSessionFileStore.kt
+++ b/android/app/src/main/java/com/noop/testcentre/ImuSessionFileStore.kt
@@ -11,6 +11,7 @@ import java.util.zip.Inflater
/** Routes realtime and delayed history IMU frames into persisted session time windows. */
class ImuSessionFileStore(private val context: Context) {
+ data class Stats(val bytes: Long, val coveredSeconds: Int)
private val prefs = context.getSharedPreferences("imu-session-windows", Context.MODE_PRIVATE)
private val directory = File(context.filesDir, "ground-truth").apply { mkdirs() }
@@ -37,6 +38,15 @@ class ImuSessionFileStore(private val context: Context) {
fun prepareForRead(id: String) = synchronized(lock) { flush(id) }
+ fun stats(id: String, from: Long, to: Long): Stats = synchronized(lock) {
+ val file = File(directory, "realtime-imu-$id.imus")
+ // Do not flush merely for UI telemetry: that would turn the intended 30-frame compression
+ // blocks into one-frame blocks. Pending raw bytes are a close live size estimate until flush.
+ val pendingBytes = pending[id].orEmpty().sumOf { it.frame.size.toLong() + 20L }
+ Stats((file.takeIf(File::isFile)?.length() ?: 0L) + pendingBytes,
+ timestamps(file).count { it in from..to })
+ }
+
/**
* Queue a wire frame for a 30-second compressed block. Delayed history may append older blocks later;
* strap timestamps, not file order, are authoritative.
diff --git a/android/app/src/main/java/com/noop/ui/GroundTruthCollectorScreen.kt b/android/app/src/main/java/com/noop/ui/GroundTruthCollectorScreen.kt
index 9df4998215..a3437dd056 100644
--- a/android/app/src/main/java/com/noop/ui/GroundTruthCollectorScreen.kt
+++ b/android/app/src/main/java/com/noop/ui/GroundTruthCollectorScreen.kt
@@ -62,12 +62,17 @@ fun GroundTruthCollectorScreen(vm: AppViewModel) {
var exportingSessionId by remember { mutableStateOf(null) }
var sessions by remember { mutableStateOf(collector.sessions()) }
var latestSensorTs by remember { mutableStateOf(null) }
+ var nowMs by remember { mutableStateOf(System.currentTimeMillis()) }
+ var captureStats by remember { mutableStateOf