Export My Data #35

Closed
opened 2026-08-18 20:37:16 -05:00 by null · 1 comment
Owner

"Your cycle belongs to you" is a promise the app cannot currently keep: there is no way to get the data out.

What is true now

No export exists. CycleRepository can read everything that would go into one — core/data/src/main/kotlin/dev/privacyllc/period/core/data/CycleRepository.kt — and nothing assembles it into a file.

What it costs

Data portability is the difference between a local-first app and a data silo, and it is the practical answer to "what if I switch phones". The Batch 06 milestone lands only when "export produces only the user's own data".

What to do

A complete, human-readable export of the user's own records — period records, spotting, and the settings that are theirs — through the system share or save sheet.

Traps

  • §45 forbids writing health history to shared external storage. Writing to Downloads/ and then sharing a path is exactly the pattern that rule exists to prevent. Use the Storage Access Framework or a FileProvider content URI, so the file lands where the user chose and nowhere else.
  • An export is a health-data file leaving the app, so it is a new entry in docs/security/SECURITY.md's third-party table the moment the user sends it somewhere. The document's Review trigger fires on "any change to what is stored, exported, backed up or logged" — it is updated in the same commit, not afterwards.
  • Only the user's own data. No derived analytics, no diagnostic payload, nothing about the device.
  • A temporary file written for sharing is still a file: it goes in cacheDir, and it is deleted after.
  • Export contains raw cycle dates, which is precisely what §45 bans from logs — do not log the export's contents while debugging it.

Why filed and not fixed

Needs the Settings screen to hang off, and the file format is a decision that outlives this batch: whatever ships first is what people's archives will be in.

Verify: Export produces a file the user chose the destination for, containing their period, spotting and settings records and nothing else, with no copy left in app-private or external storage afterwards; docs/security/SECURITY.md describes it; and no cycle date appears in logcat during the flow.

"Your cycle belongs to you" is a promise the app cannot currently keep: there is no way to get the data out. ## What is true now No export exists. `CycleRepository` can read everything that would go into one — `core/data/src/main/kotlin/dev/privacyllc/period/core/data/CycleRepository.kt` — and nothing assembles it into a file. ## What it costs Data portability is the difference between a local-first app and a data silo, and it is the practical answer to "what if I switch phones". The Batch 06 milestone lands only when "export produces only the user's own data". ## What to do A complete, human-readable export of the user's own records — period records, spotting, and the settings that are theirs — through the system share or save sheet. ## Traps - **§45 forbids writing health history to shared external storage.** Writing to `Downloads/` and then sharing a path is exactly the pattern that rule exists to prevent. Use the Storage Access Framework or a `FileProvider` content URI, so the file lands where the user chose and nowhere else. - **An export is a health-data file leaving the app**, so it is a new entry in `docs/security/SECURITY.md`'s third-party table the moment the user sends it somewhere. The document's `Review trigger` fires on "any change to what is stored, exported, backed up or logged" — it is updated in the same commit, not afterwards. - **Only the user's own data.** No derived analytics, no diagnostic payload, nothing about the device. - A temporary file written for sharing is still a file: it goes in `cacheDir`, and it is deleted after. - Export contains raw cycle dates, which is precisely what §45 bans from logs — do not log the export's contents while debugging it. ## Why filed and not fixed Needs the Settings screen to hang off, and the file format is a decision that outlives this batch: whatever ships first is what people's archives will be in. Verify: Export produces a file the user chose the destination for, containing their period, spotting and settings records and nothing else, with no copy left in app-private or external storage afterwards; `docs/security/SECURITY.md` describes it; and no cycle date appears in logcat during the flow.
null added this to the Batch 06 — Privacy and Security milestone 2026-08-18 20:37:16 -05:00
null added the
P1
label 2026-08-18 20:37:16 -05:00
null closed this issue 2026-08-19 22:13:10 -05:00
Author
Owner

Built in 9a0a644.

The format, pinned rather than described

One pretty-printed JSON file, plaintext, application/json, named records-2026-08-19.json. Checked against NotificationCopy.SENSITIVE_WORDS, because the name shows up in file pickers and sync notifications.

It is asserted byte-for-byte against core/export/src/test/resources/golden-v1.json, which doubles as the documented example so SECURITY.md links at it instead of keeping a second copy that would drift. This is the strongest protection available for something the issue says "outlives this batch".

Dates are ISO calendar dates with no timezone and no conversion. Converters.kt stores a LocalDate as its epoch day precisely so it "cannot carry a timezone by accident", and it renders identically in every time zone renders the fixture under UTC, Pacific/Kiritimati (+14) and Etc/GMT+12 requiring identical bytes — that bug would never fire in the zone it was written in.

Plaintext, and that is a decision

An earlier note in this repo said the export would be encrypted. It should not be, and SECURITY.md now records why: this file is the copy that makes a lost Keystore key survivable rather than final — the exact condition written down for ever revisiting database encryption. A passphrase would reproduce the failure that decision was taken to avoid: a forgotten secret and an archive nobody, including this app, can open. §45's "prefer encrypted backup/export formats" is scoped to backup, which this is not.

The screen says so before the picker opens: "Your PIN does not protect the file."

"Only the user's own data" is a compile error, not a review comment

:core:export is pure JVM and depends on :domain:cycle alone. Prediction, PredictionAccuracy, FertilityEstimate and CycleRecord live in :domain:prediction and are not on its classpath; kotlin("jvm") keeps android.os.Build off it too. Adding a forecast or a device fact does not compile.

The key set is asserted with assertEquals, never contains — a new field is a failing test rather than a silent addition — plus named negative assertions for ids, predictions, confidence, accuracy, device, locale, timezone and the Play entitlement.

Excluded with reasons: row ids (monotonic, so gaps disclose how many records the user deleted), timestamps (behavioural — when the user opened the app, not their health record; §14's edit provenance is already carried by source = EDITED), predictions (derived), adsRemoved (a purchase one file-edit from being granted once import exists), onboardingCompleted (app state).

The traps, each addressed

  • No shared storage. SAF writes straight into the document the user picked. No cacheDir, no FileProvider, no path handed to another app.
  • No temporary file. no copy is left anywhere in app-private storage walks cacheDir after a successful export and requires it empty. It fails the instant anybody reintroduces write-then-share.
  • Only the user's own data. Structural, above.
  • Nothing logged. checkNoHealthLogging covers app and core/export; print(/println( are matched as substrings, so PrintWriter is absent by construction.

Verification

261 JVM tests, 0 failures, 0 skipped. Five guards green:

module boundaries: 10 module(s) checked, 3 required to stay pure JVM, no violations.
themed drawables:  64 resource(s) across 6 folder pair(s), all paired.
permissions:       7 declared, all approved.
shared storage:    71 Kotlin file(s) checked, no shared-storage writes.
health logging:    71 Kotlin file(s) across 9 module(s) checked, 1 declared unable
                   to see health data, no logging calls.

Key tests: ExportFormatTest (7, including the golden and the timezone check), DataExporterTest (7, including the empty-cacheDir clause and a failing stream never reported as done).

Two new guards, both proved to fail

checkNoSharedStorageWrites — §45's shared-storage ban was enforced by nobody having typed it. checkPermissions structurally cannot see it: it matches <uses-permission>, and a <provider> declaring androidx's FileProvider merges green, with FileProvider already on the classpath via core-ktx. Proved by swapping the SAF stream for getExternalFilesDir: exactly one violation, named with its line.

checkNoHealthLogging completeness — a module missing from modulesSeeingHealthData was silently exempt with a green build, a hazard app/proguard-rules.pro has described since before :core:security and :core:export existed. Every module must now appear in that list or in an explicit modulesWithNoHealthData with its reason; being in neither is a violation. Proved by removing core/export from the list:

- core/export is in neither modulesSeeingHealthData nor modulesWithNoHealthData

One deliberate exclusion, recorded rather than overlooked

"Not yet" answers are not in v1. Both readings are defensible, so the tiebreak was reversibility: under the format contract, adding a key later is additive and safe, while removing one after archives exist is breaking. It also keeps the file matching this issue's verify line as literally written.

Built in `9a0a644`. ## The format, pinned rather than described One pretty-printed JSON file, plaintext, `application/json`, named `records-2026-08-19.json`. Checked against `NotificationCopy.SENSITIVE_WORDS`, because the name shows up in file pickers and sync notifications. It is asserted **byte-for-byte** against `core/export/src/test/resources/golden-v1.json`, which doubles as the documented example so `SECURITY.md` links at it instead of keeping a second copy that would drift. This is the strongest protection available for something the issue says "outlives this batch". Dates are ISO calendar dates with **no timezone and no conversion**. `Converters.kt` stores a `LocalDate` as its epoch day precisely so it "cannot carry a timezone by accident", and `it renders identically in every time zone` renders the fixture under UTC, `Pacific/Kiritimati` (+14) and `Etc/GMT+12` requiring identical bytes — that bug would never fire in the zone it was written in. ## Plaintext, and that is a decision An earlier note in this repo said the export would be encrypted. It should not be, and `SECURITY.md` now records why: this file is the copy that makes a lost Keystore key **survivable rather than final** — the exact condition written down for ever revisiting database encryption. A passphrase would reproduce the failure that decision was taken to avoid: a forgotten secret and an archive nobody, including this app, can open. §45's "prefer encrypted backup/export formats" is scoped to *backup*, which this is not. The screen says so before the picker opens: *"Your PIN does not protect the file."* ## "Only the user's own data" is a compile error, not a review comment `:core:export` is pure JVM and depends on `:domain:cycle` **alone**. `Prediction`, `PredictionAccuracy`, `FertilityEstimate` and `CycleRecord` live in `:domain:prediction` and are not on its classpath; `kotlin("jvm")` keeps `android.os.Build` off it too. Adding a forecast or a device fact does not compile. The key set is asserted with `assertEquals`, never `contains` — a new field is a failing test rather than a silent addition — plus named negative assertions for ids, predictions, confidence, accuracy, device, locale, timezone and the Play entitlement. Excluded with reasons: **row ids** (monotonic, so gaps disclose how many records the user *deleted*), **timestamps** (behavioural — when the user opened the app, not their health record; §14's edit provenance is already carried by `source = EDITED`), **predictions** (derived), **`adsRemoved`** (a purchase one file-edit from being granted once import exists), **`onboardingCompleted`** (app state). ## The traps, each addressed - **No shared storage.** SAF writes straight into the document the user picked. No `cacheDir`, no `FileProvider`, no path handed to another app. - **No temporary file.** `no copy is left anywhere in app-private storage` walks `cacheDir` after a successful export and requires it empty. It fails the instant anybody reintroduces write-then-share. - **Only the user's own data.** Structural, above. - **Nothing logged.** `checkNoHealthLogging` covers `app` and `core/export`; `print(`/`println(` are matched as substrings, so `PrintWriter` is absent by construction. ## Verification **261 JVM tests, 0 failures, 0 skipped.** Five guards green: ``` module boundaries: 10 module(s) checked, 3 required to stay pure JVM, no violations. themed drawables: 64 resource(s) across 6 folder pair(s), all paired. permissions: 7 declared, all approved. shared storage: 71 Kotlin file(s) checked, no shared-storage writes. health logging: 71 Kotlin file(s) across 9 module(s) checked, 1 declared unable to see health data, no logging calls. ``` Key tests: `ExportFormatTest` (7, including the golden and the timezone check), `DataExporterTest` (7, including the empty-`cacheDir` clause and a failing stream never reported as done). ## Two new guards, both proved to fail **`checkNoSharedStorageWrites`** — §45's shared-storage ban was enforced by nobody having typed it. `checkPermissions` structurally cannot see it: it matches `<uses-permission>`, and a `<provider>` declaring androidx's `FileProvider` merges green, with FileProvider already on the classpath via core-ktx. Proved by swapping the SAF stream for `getExternalFilesDir`: exactly one violation, named with its line. **`checkNoHealthLogging` completeness** — a module missing from `modulesSeeingHealthData` was *silently exempt with a green build*, a hazard `app/proguard-rules.pro` has described since before `:core:security` and `:core:export` existed. Every module must now appear in that list or in an explicit `modulesWithNoHealthData` **with its reason**; being in neither is a violation. Proved by removing `core/export` from the list: ``` - core/export is in neither modulesSeeingHealthData nor modulesWithNoHealthData ``` ## One deliberate exclusion, recorded rather than overlooked "Not yet" answers are **not** in v1. Both readings are defensible, so the tiebreak was reversibility: under the format contract, adding a key later is additive and safe, while removing one after archives exist is breaking. It also keeps the file matching this issue's verify line as literally written.
Sign in to join this conversation.
No Label
P0
P1
P2
release-blocker
No project
No Assignees
1 Participants
Notifications
Due Date
The due date is invalid or out of range. Please use the format 'yyyy-mm-dd'.

No due date set.

Dependencies

No dependencies set.

Reference: null/Privacy-Period-Tracker#35
No description provided.