Restore from an encrypted export #58

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

What is true now. DataExporter writes an encrypted archive and nothing in the tree can read one back. A user who changes phone starts the prediction model from zero, and the export — offered as the thing that makes her history hers — is a one-way door.

What to do. A parse counterpart in core/export mirroring ExportDocument.render, and an import flow beside the export screen writing through CycleRepository. The user chooses merge or replace explicitly; the app must not guess. Prediction recalculates once after the import completes, not per record.

Traps. Importing must go through confirmPeriodStart and friends rather than touching DAOs, or the forecast-lineage and scoring invariants in CycleRepository are bypassed silently. Every imported start is historical, so the backfill guard should refuse to score any of it — an import must not manufacture accuracy figures. Same no-logging discipline the exporter documents (§45): nothing about the contents in a log, an exception message, or a crash report.

Verify: a round-trip unit test in core/export — render then parse reproduces every PeriodRecord and SpottingRecord field — plus a repository test asserting an import scores nothing and triggers exactly one recalculation.

**What is true now.** `DataExporter` writes an encrypted archive and nothing in the tree can read one back. A user who changes phone starts the prediction model from zero, and the export — offered as the thing that makes her history hers — is a one-way door. **What to do.** A parse counterpart in `core/export` mirroring `ExportDocument.render`, and an import flow beside the export screen writing through `CycleRepository`. The user chooses merge or replace explicitly; the app must not guess. Prediction recalculates once after the import completes, not per record. **Traps.** Importing must go through `confirmPeriodStart` and friends rather than touching DAOs, or the forecast-lineage and scoring invariants in `CycleRepository` are bypassed silently. Every imported start is historical, so the backfill guard should refuse to score any of it — an import must not manufacture accuracy figures. Same no-logging discipline the exporter documents (§45): nothing about the contents in a log, an exception message, or a crash report. Verify: a round-trip unit test in `core/export` — render then parse reproduces every `PeriodRecord` and `SpottingRecord` field — plus a repository test asserting an import scores nothing and triggers exactly one recalculation.
null added this to the Batch 10 — Show what you already know milestone 2026-08-20 16:38:06 -05:00
null added the
P2
label 2026-08-20 16:38:06 -05:00
null closed this issue 2026-08-21 02:27:46 -05:00
Author
Owner

Landed in 0ae92bd.

The parse counterpart. core/export/ExportReader.kt over a strict hand-written Json.kt, mirroring ExportDocument.render in the same module. ExportRoundTripTest (17 tests) — render→parse reproduces every PeriodRecord and SpottingRecord field including the open period, the whole ExportedSettings by equality, and the committed golden-v1.json still reads. Refusals are typed: NotOurFile, NewerFormat(v), Damaged; unknown keys ignored (rule 2), unknown source becomes IMPORTED rather than a guess.

The traps, each held by a named test. Importing goes through CycleRepository.importHistory, not a DAO — and deliberately not through confirmPeriodStart, whose scoring, not-yet clearing and per-record snapshot are all wrong for an archive. CycleRepositoryImportTest: an import scores nothing (with an archive dated today, the case the backfill guard alone does not catch), the forecast is recalculated exactly once, however long the archive (counting engine, 4 records → 1 call), an import leaves exactly one standing forecast, every imported record says it was imported.

Merge vs replace is explicit. ImportMode.MERGE|REPLACE, no default. Replace runs through the new PeriodDatabase.replaceEverything, which empties the tables inside the same transaction as the writes — replace cannot leave the user with neither history proves it, and prove-guard confirms it: removing withTransaction from replaceEverything reddens exactly that one test.

§45. ./gradlew check green including checkNoHealthLogging; no failure carries a cause. DataImporterTest.an archive cannot change the app lock holds the one setting a file must never move.

On PeriodQA (emulator-5554, debug build of 0ae92bd): Settings → Restore from a file → Add to what is here → Downloads → records-2026-08-19.json → banner reads "Restored. 4 periods and 2 spotting days added."; Insights then shows 3 cycles (29/29/57) with accuracy still unscored; the same file a second time reads "Everything in that file was already recorded here, so nothing changed." uiautomator dump transcripts, not screenshots (FLAG_SECURE).

One correction to this issue's own text: the export is not encrypted and says so in its own prose — ExportCopy.PROTECTION_BODY and the archive's aboutThisFile. "Restore from an encrypted export" was wrong when filed; what shipped reads the plaintext archive the app actually writes.

Landed in 0ae92bd. **The parse counterpart.** `core/export/ExportReader.kt` over a strict hand-written `Json.kt`, mirroring `ExportDocument.render` in the same module. `ExportRoundTripTest` (17 tests) — render→parse reproduces every `PeriodRecord` and `SpottingRecord` field including the open period, the whole `ExportedSettings` by equality, and the committed `golden-v1.json` still reads. Refusals are typed: `NotOurFile`, `NewerFormat(v)`, `Damaged`; unknown keys ignored (rule 2), unknown `source` becomes `IMPORTED` rather than a guess. **The traps, each held by a named test.** Importing goes through `CycleRepository.importHistory`, not a DAO — and deliberately not through `confirmPeriodStart`, whose scoring, not-yet clearing and per-record snapshot are all wrong for an archive. `CycleRepositoryImportTest`: `an import scores nothing` (with an archive dated *today*, the case the backfill guard alone does not catch), `the forecast is recalculated exactly once, however long the archive` (counting engine, 4 records → 1 call), `an import leaves exactly one standing forecast`, `every imported record says it was imported`. **Merge vs replace is explicit.** `ImportMode.MERGE|REPLACE`, no default. Replace runs through the new `PeriodDatabase.replaceEverything`, which empties the tables inside the same transaction as the writes — `replace cannot leave the user with neither history` proves it, and prove-guard confirms it: removing `withTransaction` from `replaceEverything` reddens exactly that one test. **§45.** `./gradlew check` green including `checkNoHealthLogging`; no failure carries a cause. `DataImporterTest.an archive cannot change the app lock` holds the one setting a file must never move. **On PeriodQA (emulator-5554, debug build of 0ae92bd):** Settings → Restore from a file → Add to what is here → Downloads → records-2026-08-19.json → banner reads "Restored. 4 periods and 2 spotting days added."; Insights then shows 3 cycles (29/29/57) with accuracy still unscored; the same file a second time reads "Everything in that file was already recorded here, so nothing changed." `uiautomator dump` transcripts, not screenshots (FLAG_SECURE). **One correction to this issue's own text:** the export is not encrypted and says so in its own prose — `ExportCopy.PROTECTION_BODY` and the archive's `aboutThisFile`. "Restore from an encrypted export" was wrong when filed; what shipped reads the plaintext archive the app actually writes.
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#58
No description provided.