Skip to content

Privacy: add retention for scheduled debug exports - #960

Merged
ryanbr merged 1 commit into
ryanbr:mainfrom
tigercraft4:privacy/debug-export-retention
Jul 30, 2026
Merged

ryanbr merged 1 commit into
ryanbr:mainfrom
tigercraft4:privacy/debug-export-retention

Conversation

@tigercraft4

Copy link
Copy Markdown

Fixes #642, #650

Summary

Scheduled debug exports (the daily strap-log + optional raw 5/MG capture) accumulated on disk indefinitely with no cleanup and no visible cap:

  • Android (DebugExportScheduler.kt / LogExport.kt): the daily WorkManager job wrote noop-straplog-<stamp>.txt (+ .bin when a raw 5/MG capture exists) into the app-private cache/logs dir on every run, forever.
  • iOS/macOS (ScheduledDebugExport.swift): the scheduled/"Run now" export wrote noop-strap-log-<stamp>.txt (+ noop-raw-capture-<stamp>.json) into the app's Documents directory, which is Files-visible (UIFileSharingEnabled), also forever.

Both platforms now mirror the existing BackupSync keep-count retention pattern:

  • A pure scheduledExportStamp / scheduledExportStampsToPrune (Android) and exportStamp(fromFilename:) / exportStampsToPrune (Swift) pair extracts the timestamp embedded in each filename and computes which generations to delete beyond a keep-count — a day's log+raw pair shares one stamp and counts as one generation, not two. Unit tested on both platforms.
  • Pruning runs automatically after every scheduled write (Android: end of writeScheduledExport; Apple: end of performExport, so it also covers the "Run now" button).
  • Default keep-count is 14 generations on both platforms (these are small text/JSON files, not a whole-DB snapshot, so a longer default than BackupSync's 7 is reasonable).
  • The Test Centre export card on both platforms gets:
    • A "Keep last exports" picker (3 / 7 / 14 / 30 / 60), wired to the new persisted setting.
    • A manual "Clear scheduled exports" action (with a confirmation dialog) that deletes every scheduled-export file immediately, regardless of the keep-count — independent of the daily toggle so files written while it was on don't outlive a later toggle-off.
  • Interactive shares (noop-strap-log- interactive prefix on Android is spelled without the internal dash vs. the scheduled noop-straplog- prefix; on Apple, interactive saves go through the save panel / share sheet, never Documents) are never touched by retention or the clear action — only their own distinct filename prefixes are scanned.

Localization

New user-facing strings (retention picker label/blurb, clear button, confirm dialog title/message, clear-result toast/alert copy) are translated for the focus set de/es/fr/pt-PT, plus zh (Android values-zh) and zh-Hans/zh-Hant/it/ru (Apple catalog) to keep Tools/i18n_audit.py --ci's non-focus-locale ratchet from regressing. python3 Tools/i18n_audit.py --ci origin/main passes clean (exit 0).

Verification

  • Android: cd android && ./gradlew :app:compileFullDebugKotlin → BUILD SUCCESSFUL. New unit tests added (LogExportRetentionTest.kt) covering stamp extraction, keep-N pruning (pair-counts-once), no-op within budget, and that interactive-share/unrelated files are never pruned. Not run against the JVM test task in this pass — only compile was verified per the build-verification checklist; the pure logic has no Android framework dependency so it should run under testFullDebugUnitTest without issue, but I have not executed that task here.
  • Swift: xcodegen generate && xcodebuild -project Strand.xcodeproj -scheme Strand -destination 'platform=macOS' build → BUILD SUCCEEDED. Strand/System/ScheduledDebugExport.swift and Strand/Screens/TestCentreView.swift are shared between the Strand (macOS) and NOOPiOS targets (not excluded in project.yml), so the macOS build exercises the same source the iOS target compiles. New XCTest coverage added (ScheduledDebugExportTests.swift, @MainActor to match the type's isolation) but xcodebuild test was not run in this pass — only build.
  • Not verified on hardware / device: actual on-device retention behavior (WorkManager daily job pruning over real days, iOS BGAppRefresh timing interacting with the prune, Files app visibility of the pruned/cleared state) was not exercised on a real strap or device. This is filesystem bookkeeping around an existing, already-shipped export feature, not a BLE change, but a human should sanity-check the picker/clear button once in the running app on both platforms.

Scope

One concern (retention for scheduled debug exports), both platforms, per the repo's cross-platform-parity rule. No schema/DB migration involved — this is pure filesystem pruning. UI additions use only existing design-system components (NoopButton/Palette/NoopType on Android, NoopButton/StrandPalette/StrandFont/Picker on Apple) — no hardcoded colors/fonts/spacing.

…r#650)

Scheduled debug exports (strap log + optional raw 5/MG capture) accumulated
on disk indefinitely with no cleanup and no visible control:
- Android wrote noop-straplog-<stamp>.txt/.bin into cache/logs on every daily
  WorkManager run.
- iOS/macOS wrote noop-strap-log-<stamp>.txt / noop-raw-capture-<stamp>.json
  into the Files-visible Documents folder on every scheduled/"Run now" drop.

Both platforms now prune to a keep-count of generations (a log+raw pair
counts once) after every write, mirroring the existing BackupSync keep-count
pattern (pure stamp-extraction + prune-list helpers, unit tested). Default
keeps 14 generations; the Test Centre export card exposes a keep-count
picker (3/7/14/30/60) plus a manual "Clear scheduled exports" action with a
confirmation dialog, for an immediate wipe independent of the daily cap.

New user-facing strings are translated for de/es/fr/pt-PT (plus zh/zh-Hans,
it, ru to satisfy the i18n audit's non-regression ratchet).

@ryanbr ryanbr left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Checked the deletion scoping first, since that's the part that can hurt someone. It's tight on both
platforms: every delete is gated on the filename yielding a stamp, so nothing else in the folder is
reachable. The Apple side is actually stricter — it needs the extension to match too, so a file someone
dropped in Documents called noop-strap-log-notes.md survives. And noop-strap-log- doesn't match
noop-straplog-, so Android's interactive shares are safe. Fragile-looking, correct, and you tested it.

Two notes on your own caveats:

Your Kotlin tests did run. Android CI executed testFullDebugUnitTest and passed — Gradle just
doesn't name passing classes. So LogExportRetentionTest is covered.

Your Swift test has never run anywhere. StrandTests isn't in CI at all, and you ran build not
test, so ScheduledDebugExportTests is unexecuted by you and by us. Worth one xcodebuild test before
this lands — it's the only thing covering the Apple retention math.

Small thing: the two new clear-result toasts are raw English, so "new user-facing strings are translated"
isn't quite complete. LogExport.kt already has six raw toasts and no uiString, so you're matching the
file — not asking you to change it, just flagging that the audit can't see them because they go through a
variable.

@ryanbr
ryanbr merged commit b93e4e1 into ryanbr:main Jul 30, 2026
3 checks passed
vipulog pushed a commit to vipulog/noop that referenced this pull request Jul 31, 2026
…yanbr#676)

Append each anchored Oura 0x50 activity/MET record to a diagnostic JSONL sidecar for offline protocol RE — instrumentation only, never scored, never a datastore row (Tier-B honest-data invariant). Both platforms with a byte-pinned line encoder; append-only, crash-safe, deduped by a persisted ring-time high-water. Rebuild of ryanbr#447 off current main. Related ryanbr#960.
simoncad7 pushed a commit to simoncad7/noop that referenced this pull request Aug 17, 2026
…yanbr#676)

Append each anchored Oura 0x50 activity/MET record to a diagnostic JSONL sidecar for offline protocol RE — instrumentation only, never scored, never a datastore row (Tier-B honest-data invariant). Both platforms with a byte-pinned line encoder; append-only, crash-safe, deduped by a persisted ring-time high-water. Rebuild of ryanbr#447 off current main. Related ryanbr#960.
simoncad7 pushed a commit to simoncad7/noop that referenced this pull request Aug 17, 2026
…r#650) (ryanbr#960)

Scheduled debug exports (strap log + optional raw 5/MG capture) accumulated
on disk indefinitely with no cleanup and no visible control:
- Android wrote noop-straplog-<stamp>.txt/.bin into cache/logs on every daily
  WorkManager run.
- iOS/macOS wrote noop-strap-log-<stamp>.txt / noop-raw-capture-<stamp>.json
  into the Files-visible Documents folder on every scheduled/"Run now" drop.

Both platforms now prune to a keep-count of generations (a log+raw pair
counts once) after every write, mirroring the existing BackupSync keep-count
pattern (pure stamp-extraction + prune-list helpers, unit tested). Default
keeps 14 generations; the Test Centre export card exposes a keep-count
picker (3/7/14/30/60) plus a manual "Clear scheduled exports" action with a
confirmation dialog, for an immediate wipe independent of the daily cap.

New user-facing strings are translated for de/es/fr/pt-PT (plus zh/zh-Hans,
it, ru to satisfy the i18n audit's non-regression ratchet).

Co-authored-by: tigercraft4 <>
ryanbr pushed a commit that referenced this pull request Aug 27, 2026
Thanks to @pipiche38, who captured the evidence and wrote the correction.

Commands.swift claimed real_steps is server-flag-gated off and so "never emitted
for an offline NOOP-only ring". On-device captures show the status read
(2f 02 20 0b) coming back status=1 (enabled) identically from an authenticated
Oura-app session and from NOOP's own fully offline, unauthenticated connection to
the same ring, so the gate is ring-side state rather than something tied to which
client asks.

The wording gets the important things right. It replaces an absolute with an
observation and hedges it ("Do not assume 'off for NOOP' without checking a live
read"), and it keeps the separate safety claim intact - the command is the 0x20
READ verb, never an enable or set-mode write. Those two claims were entangled in
one comment and only one of them was wrong.

Comment-only; no behaviour change. OuraProtocol tests green.

FOLLOW-UP OWED, and not this PR's job. The same claim is asserted in four more
places that this does not touch: com/noop/oura/Commands.kt:30 and :172 are
word-for-word twins of the two lines corrected here, OuraLiveSource.kt:1812 says
it again, and OURA_PROTOCOL.md:989 is the source all of them cite.

The doc needs more care than a find-and-replace. It does not merely state the
gate - it uses it as an EXPLANATION: "This explains 0x7E/0x7F never once
appearing across the PR #960 live sessions - the ring isn't sending them, it is
not a NOOP decode gap." If the gate reads enabled, that explanation no longer
holds, which reopens a question that sentence had closed. Note that status=1 is
NOT evidence the ring emits those tags, so the honest correction is "gating no
longer explains the absence; the cause is unknown", not "therefore it is a decode
gap".
ryanbr added a commit that referenced this pull request Aug 27, 2026
…d a test (#1687)

Follow-up to **@pipiche38's #1629**, which corrected the claim in the two Swift files. The same assertion lived in **four more places** — and the Kotlin ones are *word-for-word copies* of the lines that PR changed, so the tree was left saying opposite things in twin files:

| Location | What it said |
|---|---|
| `com/noop/oura/Commands.kt:30` | identical to the corrected Swift line |
| `com/noop/oura/Commands.kt:172` | identical to the corrected Swift doc |
| `ble/OuraLiveSource.kt:1812` | used it to dismiss the tags' absence |
| `docs/OURA_PROTOCOL.md` §7.3 | the source all of them cite |

## The spec needed more than a find-and-replace

This is the part worth reading. §7.3 didn't merely *state* the gate — it **reasoned from it**:

> *"This explains `0x7E`/`0x7F` never once appearing across the PR #960 live sessions - the ring isn't sending them, it is not a NOOP decode gap."*

If the ring's own status read comes back **enabled**, that explanation no longer holds. The observation stands — those tags still never appeared — but the cause attributed to it doesn't. **And that sentence is what closed the investigation.**

So the correction says what is actually known and no more:

- `status=1` removes *gating* as the explanation
- it is **not** evidence the ring emits those tags
- the reason for their absence is now **unknown**
- a decode gap is back on the table as a possibility rather than ruled out

The old text is quoted in place, so anyone who cited that paragraph can see exactly what changed and why.

## A test gap the sweep exposed

The sweep found a test comment reading an all-zero fixture as *"server-gated off"* — describing the fixture as though it were a ring's answer. Reworded; and the gap it hid is now covered.

`OuraFeatureStatusTests` only ever exercised the **all-zero** reply. So the one reading anybody has actually observed on hardware — `status=1` — was the one nothing pinned. That case is now a test, asserting the **decode only**, with the same caution written into it: it says the ring reports the feature enabled, not that the ring emits `0x7E`/`0x7F`.

## Verification

Comment, doc and test only; no behaviour change. `OuraProtocol` tests green (7), Android **4590 green**, compile clean, `doc_comment_lint` clean. No app-target Swift touched, so no app-build needed.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Privacy: add retention and cleanup for scheduled debug exports

2 participants