Privacy-Period-Tracker/core/export
null 0ae92bd134 feat: let an archive come back, and refuse everything that is not one
The export was a one-way door. A user changing phone started the prediction
model from zero, which made "your history is yours" a smaller promise than it
sounded — and the archive says, in its own prose, "keep this file, a future
version of the app will be able to read it back". Nothing checked that was true.

`ExportReader` mirrors `ExportDocument.render` in the same module, over a
hand-written strict JSON reader. Strict is the point: it refuses duplicate keys,
trailing content, non-integer numbers and unsupported escapes, because a parser
that quietly accepts a trailing comma is a parser that will one day accept
somebody else's file and import it as a cycle history. A missing magic string is
`NotOurFile`; a version above this build's is `NewerFormat` rather than a
half-read; unknown *keys* are ignored, which is what lets the format grow; an
unknown `source` becomes `IMPORTED` rather than a guess about the user.

The write is `CycleRepository.importHistory`, not a loop over
`confirmPeriodStart`, because that would score the standing forecast against
history the app never predicted — and the backfill guard does not catch all of
it, since a file exported this morning carries this morning's period. So the
scoring is not dodged by accident of date; it is not reached. The engine runs
exactly once, at the end, over the whole history. Merge keeps what is on this
phone untouched (§14 — a file does not get to rewrite a record she made here),
and replace goes through the new `PeriodDatabase.replaceEverything`, which
empties the tables inside the same transaction as the writes: a failure between
the wipe and the inserts would leave her with neither her own history nor the
file's. Delete My Data still calls `deleteEverything` and still gets its VACUUM —
replacing a history is not erasing one.

The screen asks the one question the app must not answer for her, before the
picker rather than after, because the screen that asked is the screen a re-lock
destroys. Add is the filled button and needs no confirmation; replace is
confirmed by a dialog that names the deletion and points back at the safe
option. The result lands in the banner above the tabs, beside the export's, as a
live region — it arrives with no focus change.

The archive cannot touch the app lock. It carries `biometricUnlock` and the
importer does not apply it: there is no PIN in the file and there cannot be.

Also raises the lock ViewModel test's hang budget to three minutes. 60s passed
alone and failed when four modules ran in one invocation — three real PBKDF2
derivations at 210k iterations. The budget is for catching a stuck coroutine,
not a slow one.

Verified: `:core:export` round trip against the committed golden file (17);
`CycleRepositoryImportTest` — one recalculation for a four-record archive,
nothing scored, one standing forecast, and a failed replace leaving the history
intact (prove-guard: remove `withTransaction` from `replaceEverything`, exactly
one red); `DataImporterTest`, `ImportScreenTest`, `ImportControllerTest`,
`ImportCopyTest`. On PeriodQA: Settings → Restore → Add → Downloads →
records-2026-08-19.json gives "Restored. 4 periods and 2 spotting days added.",
Insights then shows three cycles and accuracy still unscored, and the same file
a second time gives "Everything in that file was already recorded here, so
nothing changed."

closes #58

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 02:27:42 -05:00
..
src feat: let an archive come back, and refuse everything that is not one 2026-08-21 02:27:42 -05:00
build.gradle.kts feat: export my data, as one plaintext file the user places 2026-08-19 22:13:07 -05:00