From 9a0a644fdb79b25fc5ba498a463cfce17d3fb446 Mon Sep 17 00:00:00 2001 From: null Date: Wed, 19 Aug 2026 22:13:07 -0500 Subject: [PATCH] feat: export my data, as one plaintext file the user places MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Your cycle belongs to you" was a promise the app could not keep: there was no way to get the data out. ## The format, because it outlives the batch One pretty-printed JSON file. The issue asks for "human-readable" and means it — this is what somebody's archive will be in for years, so it is a contract with eight rules written down beside it, not an implementation detail. Pinned byte-for-byte against a committed golden file, which doubles as the documented example so SECURITY.md links at it rather than keeping a second copy that would drift. A reformat, a reordered key or a changed date rendering all fail in a test rather than in an archive. Dates are ISO calendar dates with no timezone and no conversion, ever. Converters.kt stores a LocalDate as its epoch day precisely so it "cannot carry a timezone by accident", and a zone-aware formatter here would shift every date for users east or west of whoever wrote it — a cycle tracker off by one day is wrong in the way that matters. There is a test that renders the same fixture in UTC, +14 and -12 and requires identical bytes, because that bug would never fire where it was written. ## Plaintext, and that is the decision rather than the default An earlier note said this would be encrypted. It should not be, and SECURITY.md now says why: the export is the copy that makes a lost Keystore key survivable instead of final — the exact condition recorded for ever revisiting database encryption. Putting it behind a passphrase reproduces 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. ## Only the user's own data, as a compile error :core:export is pure JVM and depends on :domain:cycle alone. Prediction, PredictionAccuracy, FertilityEstimate and CycleRecord live in :domain:prediction and are simply not on its classpath, and kotlin("jvm") keeps android.os.Build off it too — so a forecast or a device fact cannot be added by accident. The key set is asserted with assertEquals rather than contains, so a new field is a failing test rather than a silent addition. Row ids are out because they are monotonic and would disclose how many records the user DELETED. The Play entitlement is out because a purchase one file-edit away from being granted is a purchase that will be. ## No second copy, ever The Storage Access Framework writes straight into the document the user picked. The alternative — write to cacheDir, share by FileProvider, delete after — creates the temporary file the issue warns about and races the receiving app still reading it. A test walks cacheDir after a successful export and requires it empty; it fails the moment anybody reintroduces that pattern. The destination is parked until the session is unlocked. Returning from the picker can re-lock, and writing while locked would hand the whole history to whoever took the phone during the save dialog. ## 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 , and a declaring FileProvider merges green. checkNoHealthLogging gains a completeness check. A module missing from modulesSeeingHealthData was silently exempt with a green build, which app/proguard-rules.pro has described as a hazard since before :core:security and :core:export existed. Every module must now be in that list or in an explicit modulesWithNoHealthData with its reason; being in neither is a violation rather than an exemption. 261 JVM tests, none skipped. Five guards green. closes #35 Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 3 +- app/build.gradle.kts | 1 + .../dev/privacyllc/period/MainActivity.kt | 41 ++++ .../period/feature/export/DataExporter.kt | 121 ++++++++++++ .../period/feature/export/ExportController.kt | 112 +++++++++++ .../period/feature/export/ExportCopy.kt | 72 +++++++ .../period/feature/export/ExportHost.kt | 79 ++++++++ .../period/feature/export/ExportMapping.kt | 56 ++++++ .../period/feature/export/ExportScreen.kt | 116 +++++++++++ .../period/feature/export/ExportViewModel.kt | 49 +++++ .../period/feature/settings/SettingsScreen.kt | 43 ++-- .../privacyllc/period/navigation/PeriodApp.kt | 17 ++ .../period/feature/export/DataExporterTest.kt | 177 +++++++++++++++++ build.gradle.kts | 153 ++++++++++++++- core/export/build.gradle.kts | 22 +++ .../period/core/export/ExportDocument.kt | 185 ++++++++++++++++++ .../period/core/export/ExportFixture.kt | 78 ++++++++ .../period/core/export/ExportFormatTest.kt | 168 ++++++++++++++++ core/export/src/test/resources/golden-v1.json | 62 ++++++ docs/architecture/README.md | 23 ++- docs/security/SECURITY.md | 26 +++ settings.gradle.kts | 1 + 22 files changed, 1582 insertions(+), 23 deletions(-) create mode 100644 app/src/main/kotlin/dev/privacyllc/period/feature/export/DataExporter.kt create mode 100644 app/src/main/kotlin/dev/privacyllc/period/feature/export/ExportController.kt create mode 100644 app/src/main/kotlin/dev/privacyllc/period/feature/export/ExportCopy.kt create mode 100644 app/src/main/kotlin/dev/privacyllc/period/feature/export/ExportHost.kt create mode 100644 app/src/main/kotlin/dev/privacyllc/period/feature/export/ExportMapping.kt create mode 100644 app/src/main/kotlin/dev/privacyllc/period/feature/export/ExportScreen.kt create mode 100644 app/src/main/kotlin/dev/privacyllc/period/feature/export/ExportViewModel.kt create mode 100644 app/src/test/kotlin/dev/privacyllc/period/feature/export/DataExporterTest.kt create mode 100644 core/export/build.gradle.kts create mode 100644 core/export/src/main/kotlin/dev/privacyllc/period/core/export/ExportDocument.kt create mode 100644 core/export/src/test/kotlin/dev/privacyllc/period/core/export/ExportFixture.kt create mode 100644 core/export/src/test/kotlin/dev/privacyllc/period/core/export/ExportFormatTest.kt create mode 100644 core/export/src/test/resources/golden-v1.json diff --git a/README.md b/README.md index 2c3f3ec..425269c 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,8 @@ next work. Every row below cites the file or test that proves it. | Fertility window and ovulation estimate | Built | `domain/prediction/src/main/kotlin/dev/privacyllc/period/domain/prediction/FertilityEstimate.kt`, 11 tests; shown on Today and Calendar | | Discreet notifications | Built | `core/notifications` (24 JVM tests), instrumented `NotificationPrivacyTest` | | App lock (PIN + fingerprint) | Built | `core/security` (Keystore-backed verifier, lockout policy), `app/src/main/kotlin/dev/privacyllc/period/lock` gate; 28 JVM tests plus `KeystoreVerifierTest` run on `PeriodMinSdk26` and `PeriodQA` | -| Export, monetization | Not built | Batches 06–07 | +| Export My Data | Built | `core/export` (format pinned byte-for-byte against `core/export/src/test/resources/golden-v1.json`), written through the Storage Access Framework by `app/src/main/kotlin/dev/privacyllc/period/feature/export` | +| Monetization | Not built | Batch 07 | | QA | Round 3 run, partial | [docs/qa/ClaudeReport.md](docs/qa/ClaudeReport.md) — partial at `0451fbe`; TalkBack, text scaling, `minSdk` and a real locked screen still unreached | `BaselinePredictionEngine` is still in the tree, but it stopped being the diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 2bb0b4f..75a3a82 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -70,6 +70,7 @@ dependencies { implementation(project(":core:datastore")) implementation(project(":core:notifications")) implementation(project(":core:security")) + implementation(project(":core:export")) implementation(project(":domain:cycle")) implementation(project(":domain:prediction")) diff --git a/app/src/main/kotlin/dev/privacyllc/period/MainActivity.kt b/app/src/main/kotlin/dev/privacyllc/period/MainActivity.kt index 585d628..209a082 100644 --- a/app/src/main/kotlin/dev/privacyllc/period/MainActivity.kt +++ b/app/src/main/kotlin/dev/privacyllc/period/MainActivity.kt @@ -1,9 +1,11 @@ package dev.privacyllc.period +import android.content.ActivityNotFoundException import android.content.Intent import android.os.Bundle import android.view.WindowManager import androidx.activity.compose.setContent +import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.enableEdgeToEdge import androidx.fragment.app.FragmentActivity import androidx.lifecycle.Lifecycle @@ -12,9 +14,11 @@ import androidx.lifecycle.repeatOnLifecycle import dagger.hilt.android.AndroidEntryPoint import dev.privacyllc.period.core.security.AppLockRepository import dev.privacyllc.period.designsystem.PeriodTheme +import dev.privacyllc.period.feature.export.ExportController import dev.privacyllc.period.lock.AppLockController import dev.privacyllc.period.lock.AppLockGate import dev.privacyllc.period.navigation.PeriodRoot +import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.launch import javax.inject.Inject @@ -35,6 +39,27 @@ class MainActivity : FragmentActivity() { @Inject lateinit var appLock: AppLockRepository + @Inject lateinit var exportController: ExportController + + /** + * The save dialog, registered as an activity field. + * + * Registered here rather than in a composable because the picker leaves the + * app entirely: the process can be killed while it is open, and coming back + * can re-lock. An activity-scoped registration is restored by + * `ActivityResultRegistry` from saved state, so the destination the user + * chose still arrives. A launcher remembered inside the composition would + * not survive the re-lock that tears that composition down. + * + * `CreateDocument("application/json")` and never the deprecated no-arg + * constructor — that one requests a wildcard MIME type, and the picker then + * declines to append the extension. + */ + private val createExport = + registerForActivityResult(ActivityResultContracts.CreateDocument("application/json")) { uri -> + exportController.onDestination(uri) + } + override fun onCreate(savedInstanceState: Bundle?) { // Set BEFORE anything can be drawn, and cleared later only once the // store has confirmed there is no lock. Reading the preference first @@ -59,6 +84,22 @@ class MainActivity : FragmentActivity() { } } + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + exportController.launchRequest.filterNotNull().collect { fileName -> + exportController.onPickerLaunched() + // Throws SYNCHRONOUSLY on managed profiles and some OEM + // builds where no document provider is reachable, so it + // cannot be left to the result callback. + try { + createExport.launch(fileName) + } catch (unavailable: ActivityNotFoundException) { + exportController.onFailed(partialFileLeft = false) + } + } + } + } + setContent { PeriodTheme { AppLockGate { diff --git a/app/src/main/kotlin/dev/privacyllc/period/feature/export/DataExporter.kt b/app/src/main/kotlin/dev/privacyllc/period/feature/export/DataExporter.kt new file mode 100644 index 0000000..23470ae --- /dev/null +++ b/app/src/main/kotlin/dev/privacyllc/period/feature/export/DataExporter.kt @@ -0,0 +1,121 @@ +package dev.privacyllc.period.feature.export + +import android.content.Context +import android.net.Uri +import android.provider.DocumentsContract +import dev.privacyllc.period.core.data.CycleRepository +import dev.privacyllc.period.core.datastore.UserPreferencesRepository +import dev.privacyllc.period.core.export.ExportDocument +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import java.time.Clock +import java.time.LocalDate +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Writes the export straight into the document the user chose. + * + * ## There is never a second copy + * + * `#35`'s verify line asks for "no copy left in app-private or external storage + * afterwards", and the way to satisfy that is to never make one. The Storage + * Access Framework hands back a writable `content://` URI for the document the + * user picked, so the bytes go from memory to their file. The alternative — + * write to `cacheDir`, share it with a `FileProvider`, delete it after — creates + * exactly the temporary file the issue warns about, and its deletion races the + * receiving app still reading it. + * + * `takePersistableUriPermission` is deliberately **not** called. A persisted + * write grant to a file of cycle dates is a capability that outlives the reason + * for it. + * + * ## Nothing here logs + * + * `app` is in `modulesSeeingHealthData`, and this is the one function in the + * project that holds every record as a single string. `print(` and `println(` + * are matched as substrings by the guard, which is why `PrintWriter` is absent + * too. Failures are reported as states, never as messages carrying a cause. + */ +@Singleton +class DataExporter @Inject constructor( + private val repository: CycleRepository, + private val preferences: UserPreferencesRepository, + private val controller: ExportController, + private val clock: Clock, +) { + + /** + * Its own scope, living as long as the process. + * + * Not `viewModelScope`: leaving the export screen — or the app re-locking + * while a document provider is still finishing — would cancel the write + * halfway and leave a truncated file at a destination the user believes + * holds their history. + * + * The handler is a backstop and is empty for the reason `PeriodApplication` + * gives: nothing on this path may be logged, and an exception here would + * carry the record it failed on. Failures are caught explicitly below and + * reported as state. + */ + private val scope = CoroutineScope( + SupervisorJob() + Dispatchers.IO + CoroutineExceptionHandler { _, _ -> }, + ) + + /** + * Serialise everything, then write once. + * + * Built fully in memory before the stream is opened, so a failure while + * reading the database cannot leave a half-written file at a destination the + * user believes now holds their history. A realistic archive is tens of + * kilobytes; there is nothing to stream. + */ + fun export(context: Context, uri: Uri) { + controller.onWriting() + scope.launch { + var wroteAnything = false + try { + val periods = repository.allPeriods.first() + val spotting = repository.spotting.first() + val settings = preferences.preferences.first().toExportedSettings() + + val document = ExportDocument.render( + periods = periods, + spotting = spotting, + settings = settings, + appVersion = installedVersionName(context), + exportedOn = LocalDate.now(clock), + ) + + // "wt" truncates. The picker has already created the document, + // and without it a shorter export would leave the tail of a + // previous one behind. + val stream = context.contentResolver.openOutputStream(uri, "wt") + ?: throw IllegalStateException("the document provider returned no stream") + + stream.use { out -> + wroteAnything = true + out.write(document.toByteArray(Charsets.UTF_8)) + out.flush() + } + // Declared only after `use` returned: it rethrows a failure from + // close(), and a buffered write that fails on flush would + // otherwise be reported as a complete export. + controller.onDone() + } catch (failure: Exception) { + // ACTION_CREATE_DOCUMENT already created a zero-byte file at the + // destination before this ran, so a failure always leaves + // something behind unless it is removed. + val removed = runCatching { + DocumentsContract.deleteDocument(context.contentResolver, uri) + }.getOrDefault(false) + + controller.onFailed(partialFileLeft = !removed && wroteAnything) + } + } + } +} diff --git a/app/src/main/kotlin/dev/privacyllc/period/feature/export/ExportController.kt b/app/src/main/kotlin/dev/privacyllc/period/feature/export/ExportController.kt new file mode 100644 index 0000000..ddf156a --- /dev/null +++ b/app/src/main/kotlin/dev/privacyllc/period/feature/export/ExportController.kt @@ -0,0 +1,112 @@ +package dev.privacyllc.period.feature.export + +import android.net.Uri +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import javax.inject.Inject +import javax.inject.Singleton + +/** Where an export has got to. One shot, cleared when the user acknowledges it. */ +enum class ExportOutcome { + IDLE, + + /** The user picked a destination while the app was locked. Nothing is written yet. */ + PENDING_UNLOCK, + + WRITING, + + DONE, + + /** Failed, and the empty file the picker created was cleaned up. */ + FAILED, + + /** + * Failed, and a partial or empty file is still sitting at the destination. + * + * Its own outcome because the user has something to do about it, and + * because "nothing happened" and "a broken file is in your Drive" must not + * read the same. + */ + FAILED_PARTIAL_FILE, +} + +/** + * The export's state, held for as long as the process rather than a screen. + * + * Same shape and same reason as `AppLockController`. Picking a destination + * leaves the app for another app's UI, and coming back can re-lock — which + * clears every nav-scoped ViewModel. State that lived in one would be gone + * exactly when the result arrives, so a user would return to a screen that had + * forgotten it asked. + * + * Nothing here is persisted. A destination the user chose before killing the app + * is not a destination they still want, and reviving it silently later would be + * a write they did not ask for. + */ +@Singleton +class ExportController @Inject constructor() { + + private val _outcome = MutableStateFlow(ExportOutcome.IDLE) + val outcome: StateFlow = _outcome.asStateFlow() + + /** Non-null when a picker should be opened, carrying the suggested file name. */ + private val _launchRequest = MutableStateFlow(null) + val launchRequest: StateFlow = _launchRequest.asStateFlow() + + /** Where the user chose to put it. Held until the session is unlocked. */ + private val _destination = MutableStateFlow(null) + val destination: StateFlow = _destination.asStateFlow() + + fun requestPicker(fileName: String) { + _outcome.value = ExportOutcome.IDLE + _launchRequest.value = fileName + } + + /** Consumed by the activity once the picker is open. */ + fun onPickerLaunched() { + _launchRequest.value = null + } + + /** + * The picker came back. + * + * A null uri is a cancellation, and cancelling says nothing at all — no + * message, no state. The user changed their mind, which is not an event. + */ + fun onDestination(uri: Uri?) { + if (uri == null) { + _outcome.value = ExportOutcome.IDLE + return + } + _destination.value = uri + _outcome.value = ExportOutcome.PENDING_UNLOCK + } + + /** + * Take the destination, once, to write it. + * + * Cleared as it is handed over so a relock/unlock cycle cannot write the + * same export twice — a second write into a document the user has already + * received is at best confusing and at worst a file they thought was + * finished changing under them. + */ + fun takeDestination(): Uri? = _destination.value.also { _destination.value = null } + + fun onWriting() { + _outcome.value = ExportOutcome.WRITING + } + + fun onDone() { + _outcome.value = ExportOutcome.DONE + } + + fun onFailed(partialFileLeft: Boolean) { + _outcome.value = + if (partialFileLeft) ExportOutcome.FAILED_PARTIAL_FILE else ExportOutcome.FAILED + } + + fun acknowledge() { + _outcome.value = ExportOutcome.IDLE + } +} diff --git a/app/src/main/kotlin/dev/privacyllc/period/feature/export/ExportCopy.kt b/app/src/main/kotlin/dev/privacyllc/period/feature/export/ExportCopy.kt new file mode 100644 index 0000000..d2ffbca --- /dev/null +++ b/app/src/main/kotlin/dev/privacyllc/period/feature/export/ExportCopy.kt @@ -0,0 +1,72 @@ +package dev.privacyllc.period.feature.export + +import java.time.LocalDate +import java.time.format.DateTimeFormatter + +/** + * Everything the export screen says, in one place so it can be tested. + * + * The disclosure is the substance of this feature, not decoration around it. + * Exporting is the single moment health data leaves the app's sandbox, and the + * app's own protections stop at the edge of that file — so the screen says so + * before the picker opens, not after. + */ +internal object ExportCopy { + + const val ROW_TITLE = "Export my data" + const val ROW_SUBTITLE = "Save a copy of your records somewhere you choose" + + const val TITLE = "Export my data" + + const val WHAT_HEADING = "What you get" + const val WHAT_BODY = + "One file holding every period and spotting entry you have logged, and your " + + "settings. It is plain text you can open and read.\n\n" + + "Nothing else goes in it: no predictions, no averages, nothing about your " + + "phone, and nothing that was ever sent anywhere." + + const val WHERE_HEADING = "Where it goes" + const val WHERE_BODY = + "You choose. The next screen is your phone's own save dialog, and the file is " + + "written straight there — no copy is kept inside the app." + + /** + * The load-bearing paragraph, and it is paid for immediately by the line + * that follows it. + * + * A user who has just set a PIN reasonably assumes it protects everything + * this app touches. It does not reach a file that has left, and saying so is + * the difference between a feature and a trap. + */ + const val PROTECTION_HEADING = "Your PIN does not protect the file" + const val PROTECTION_BODY = + "The file is not encrypted, and the app lock has no reach over it. Anyone who " + + "can open the folder you save it to can read it, and a folder that syncs " + + "will take it wherever it syncs.\n\n" + + "That is the trade for having your own copy: nothing here is backed up " + + "anywhere, so this file is the only way your records can outlive this phone." + + const val ACTION = "Choose where to save" + const val WORKING = "Saving…" + + const val DONE = "Saved. Your records are in the file you chose." + const val FAILED = "Nothing was saved, and your records are intact." + const val FAILED_PARTIAL = + "Something went wrong while saving, and an incomplete file may be left where " + + "you chose. Delete it and try again — your records here are intact." + const val ACKNOWLEDGE = "Done" + + /** + * `records-2026-08-19.json`. + * + * Checked against `NotificationCopy.SENSITIVE_WORDS`: the name is visible in + * a file picker, a downloads list and a sync notification, so it must not + * say what the app is for. "records" is deliberately dull. + * + * The date is the day the file was saved, not a cycle date — it is what + * makes two archives distinguishable in a folder. Identity lives inside the + * file's `format` field, never in its name. + */ + fun fileName(on: LocalDate): String = + "records-${on.format(DateTimeFormatter.ISO_LOCAL_DATE)}.json" +} diff --git a/app/src/main/kotlin/dev/privacyllc/period/feature/export/ExportHost.kt b/app/src/main/kotlin/dev/privacyllc/period/feature/export/ExportHost.kt new file mode 100644 index 0000000..86f4227 --- /dev/null +++ b/app/src/main/kotlin/dev/privacyllc/period/feature/export/ExportHost.kt @@ -0,0 +1,79 @@ +package dev.privacyllc.period.feature.export + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.semantics.LiveRegionMode +import androidx.compose.ui.semantics.liveRegion +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle + +/** + * Writes a parked export, and reports what happened — wherever the user is. + * + * ## Why the result is not shown on the export screen + * + * Choosing a destination leaves the app. Coming back can re-lock it, and the + * user then unlocks onto Today rather than onto the screen they started from — + * so a result rendered by `ExportScreen` would frequently be rendered to nobody. + * "Nothing happened" and "a broken file is sitting in your Drive" look identical + * from the outside, and only one of them needs the user to do something. + * + * One renderer, hosted above the tabs, driven by `ExportController`. + * + * ## The write happens here because here is after the unlock + * + * This composable exists only inside the gate's unlocked branch. Draining the + * destination anywhere earlier would let somebody who picked up the phone during + * the save dialog receive the entire history without ever passing the lock. + */ +@Composable +fun ExportHost(viewModel: ExportViewModel = hiltViewModel()) { + val context = LocalContext.current + val destination by viewModel.destination.collectAsStateWithLifecycle() + val outcome by viewModel.outcome.collectAsStateWithLifecycle() + + LaunchedEffect(destination) { + if (destination != null) viewModel.writePendingExport(context) + } + + val message = when (outcome) { + ExportOutcome.DONE -> ExportCopy.DONE + ExportOutcome.FAILED -> ExportCopy.FAILED + ExportOutcome.FAILED_PARTIAL_FILE -> ExportCopy.FAILED_PARTIAL + // A cancelled picker says nothing at all: the user changed their mind, + // which is not an event worth a message. + else -> null + } ?: return + + Surface(color = MaterialTheme.colorScheme.surfaceVariant) { + Column(Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 12.dp)) { + Text( + message, + style = MaterialTheme.typography.bodyMedium, + color = if (outcome == ExportOutcome.DONE) { + MaterialTheme.colorScheme.onSurfaceVariant + } else { + MaterialTheme.colorScheme.error + }, + // The most important accessibility line in this feature. The + // result arrives after returning from another app's UI, with no + // focus change, so without a live region a screen-reader user is + // never told whether their data was saved. + modifier = Modifier.semantics { liveRegion = LiveRegionMode.Polite }, + ) + TextButton(onClick = viewModel::acknowledge) { Text(ExportCopy.ACKNOWLEDGE) } + } + } +} diff --git a/app/src/main/kotlin/dev/privacyllc/period/feature/export/ExportMapping.kt b/app/src/main/kotlin/dev/privacyllc/period/feature/export/ExportMapping.kt new file mode 100644 index 0000000..97d3ec1 --- /dev/null +++ b/app/src/main/kotlin/dev/privacyllc/period/feature/export/ExportMapping.kt @@ -0,0 +1,56 @@ +package dev.privacyllc.period.feature.export + +import android.content.Context +import dev.privacyllc.period.core.datastore.UserPreferences +import dev.privacyllc.period.core.export.ExportedSettings + +/** + * `UserPreferences` to the export's own type, explicitly and totally. + * + * This function exists so that adding a preference is a **compile error** until + * somebody decides whether it belongs in an archive. Mapping field-by-field is + * the point; a reflective or wholesale copy would carry every future field out + * of the app by default, which is the opposite of what an export of "only the + * user's own data" should do. + * + * Two fields are excluded on purpose: + * + * - **`adsRemoved`** — a Play entitlement whose own KDoc names Play as the + * source of truth. Once an import path exists, a purchase that could be + * granted by editing one line of a text file is a purchase that will be. + * - **`onboardingCompleted`** — application state, not a fact about the user. + * Nobody's archive is improved by knowing they finished a wizard. + * + * `checkInCount` is not on `UserPreferences` at all; it is stored separately and + * is likewise app state. + */ +internal fun UserPreferences.toExportedSettings(): ExportedSettings = ExportedSettings( + notificationPrivacy = notificationPrivacy.name, + reminderTime = reminderTime, + periodApproaching = periodApproachingEnabled, + periodExpectedToday = periodExpectedTodayEnabled, + didItStart = didItStartEnabled, + periodEndCheckIn = periodEndCheckInEnabled, + fertileWindow = fertileWindowReminderEnabled, + ovulation = ovulationReminderEnabled, + // Named for what it is. `biometricLockEnabled` controls whether a + // fingerprint may stand in for the PIN — it is NOT "the app lock is on", + // which is derived from whether a PIN exists and lives in :core:security, + // where the export cannot reach and should not. + biometricUnlock = biometricLockEnabled, + theme = theme.name, +) + +/** + * The installed version, read from the package manager rather than a + * `BuildConfig` constant — so it reports what is actually on the device, which + * is the number a person reads out when something is wrong. + * + * Plain function rather than the `@Composable` it was extracted from, because + * the exporter needs it off the main thread and two implementations of one fact + * is how they come to disagree. + */ +internal fun installedVersionName(context: Context): String = + runCatching { + context.packageManager.getPackageInfo(context.packageName, 0).versionName + }.getOrNull() ?: "unknown" diff --git a/app/src/main/kotlin/dev/privacyllc/period/feature/export/ExportScreen.kt b/app/src/main/kotlin/dev/privacyllc/period/feature/export/ExportScreen.kt new file mode 100644 index 0000000..8996681 --- /dev/null +++ b/app/src/main/kotlin/dev/privacyllc/period/feature/export/ExportScreen.kt @@ -0,0 +1,116 @@ +package dev.privacyllc.period.feature.export + +import android.content.res.Configuration +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import dev.privacyllc.period.designsystem.PeriodTheme + +/** + * The disclosure, and one button. + * + * No dialog and no illustration. A dialog is for a decision that needs + * interrupting; this needs *reading*, and the three headings are the whole + * point of the screen. `docs/design/README.md` rules out a padlock motif for the + * lock screens, and it would be worse here — a padlock over a screen explaining + * that the file is **not** protected asserts the opposite of the text. + * + * The outcome is deliberately not rendered here. With a PIN set, the user + * returns from the picker to the lock screen and unlocks onto Today, so this + * screen may not exist by the time the write finishes. `PeriodApp` renders the + * result above the nav host instead, where they actually land. + */ +@Composable +fun ExportScreen(viewModel: ExportViewModel = hiltViewModel()) { + val outcome by viewModel.outcome.collectAsStateWithLifecycle() + ExportScreenContent( + working = outcome == ExportOutcome.WRITING, + onExport = viewModel::begin, + ) +} + +@Composable +internal fun ExportScreenContent(working: Boolean, onExport: () -> Unit) { + Surface(modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(24.dp), + ) { + Text( + ExportCopy.TITLE, + style = MaterialTheme.typography.headlineMedium, + modifier = Modifier.semantics { heading() }, + ) + + Section(ExportCopy.WHAT_HEADING, ExportCopy.WHAT_BODY) + Section(ExportCopy.WHERE_HEADING, ExportCopy.WHERE_BODY) + Section( + ExportCopy.PROTECTION_HEADING, + ExportCopy.PROTECTION_BODY, + emphasised = true, + ) + + Spacer(Modifier.height(28.dp)) + + Button( + onClick = onExport, + enabled = !working, + modifier = Modifier.fillMaxWidth(), + ) { + Text(if (working) ExportCopy.WORKING else ExportCopy.ACTION) + } + } + } +} + +@Composable +private fun Section(heading: String, body: String, emphasised: Boolean = false) { + Spacer(Modifier.height(24.dp)) + Text( + heading, + style = MaterialTheme.typography.titleMedium, + color = if (emphasised) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface, + // Announced as a heading so a screen reader can move between the three + // sections rather than hearing one long paragraph. + modifier = Modifier.semantics { heading() }, + ) + Spacer(Modifier.height(6.dp)) + Text( + body, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) +} + +@Preview(name = "Export · light", showBackground = true, heightDp = 1000) +@Preview( + name = "Export · dark", + showBackground = true, + heightDp = 1000, + uiMode = Configuration.UI_MODE_NIGHT_YES, +) +@Preview(name = "Export · font 2.0", showBackground = true, heightDp = 2000, fontScale = 2.0f) +@Composable +private fun ExportPreview() { + PeriodTheme { ExportScreenContent(working = false, onExport = {}) } +} diff --git a/app/src/main/kotlin/dev/privacyllc/period/feature/export/ExportViewModel.kt b/app/src/main/kotlin/dev/privacyllc/period/feature/export/ExportViewModel.kt new file mode 100644 index 0000000..7ba4363 --- /dev/null +++ b/app/src/main/kotlin/dev/privacyllc/period/feature/export/ExportViewModel.kt @@ -0,0 +1,49 @@ +package dev.privacyllc.period.feature.export + +import android.content.Context +import androidx.lifecycle.ViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.StateFlow +import java.time.Clock +import java.time.LocalDate +import javax.inject.Inject + +/** + * Thin on purpose. + * + * It asks for a picker and reads the outcome; it holds nothing. Everything that + * has to survive leaving the app — the chosen destination, the result — lives in + * `ExportController`, because returning from the picker can re-lock the app and + * a re-lock clears every nav-scoped ViewModel, including this one. + */ +@HiltViewModel +class ExportViewModel @Inject constructor( + private val controller: ExportController, + private val exporter: DataExporter, + private val clock: Clock, +) : ViewModel() { + + val outcome: StateFlow = controller.outcome + + /** Non-null while a chosen destination is waiting to be written. */ + val destination = controller.destination + + fun begin() { + controller.requestPicker(ExportCopy.fileName(LocalDate.now(clock))) + } + + /** + * Write the destination the user picked, now that the session is unlocked. + * + * Called only from inside the gate's unlocked branch. Writing while locked + * would hand the whole history to somebody who took the phone during the + * save dialog — the one window in this feature where the lock could be + * walked around. + */ + fun writePendingExport(context: Context) { + val uri = controller.takeDestination() ?: return + exporter.export(context.applicationContext, uri) + } + + fun acknowledge() = controller.acknowledge() +} diff --git a/app/src/main/kotlin/dev/privacyllc/period/feature/settings/SettingsScreen.kt b/app/src/main/kotlin/dev/privacyllc/period/feature/settings/SettingsScreen.kt index 9951e4f..48ba592 100644 --- a/app/src/main/kotlin/dev/privacyllc/period/feature/settings/SettingsScreen.kt +++ b/app/src/main/kotlin/dev/privacyllc/period/feature/settings/SettingsScreen.kt @@ -31,6 +31,8 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import dev.privacyllc.period.R +import dev.privacyllc.period.feature.export.ExportCopy +import dev.privacyllc.period.feature.export.installedVersionName import dev.privacyllc.period.designsystem.PeriodTheme /** @@ -43,19 +45,25 @@ import dev.privacyllc.period.designsystem.PeriodTheme * something behind them, and **a row for an unbuilt feature is absent rather * than disabled**. * - * That is deliberate. A greyed-out "Export My Data" is a promise with no - * delivery date, and in a privacy app specifically it is worse than silence: a - * user who sees a disabled Delete My Data has been told the feature exists and - * that they may not have it. A section that is not there yet is simply not - * there yet, and the tracker is where the plan lives. + * That is deliberate. A greyed-out row is a promise with no delivery date, and + * in a privacy app specifically it is worse than silence: a user who sees a + * disabled Delete My Data has been told the feature exists and that they may + * not have it. A section that is not there yet is simply not there yet, and the + * tracker is where the plan lives. * - * So Privacy & Security arrives with #34 to #37, Appearance with Batch 08's - * theme control, and Premium with Batch 07. + * The worked example here used to be "Export My Data", which now ships a few + * lines below — so the current one is the **Privacy Policy** row, which is + * absent because no hosted page exists and a link that 404s is worse than no + * link. It arrives with the page. + * + * So Privacy & Security arrived with #34 to #37, Appearance comes with Batch + * 08's theme control, and Premium with Batch 07. */ @Composable fun SettingsScreen( onOpenNotifications: () -> Unit, onOpenAppLock: () -> Unit, + onOpenExport: () -> Unit, viewModel: PrivacyViewModel? = hiltViewModel(), ) { val deletion = viewModel?.deletion?.collectAsStateWithLifecycle()?.value ?: DeletionState.IDLE @@ -82,6 +90,11 @@ fun SettingsScreen( subtitle = "Ask for a PIN before the app opens", onClick = onOpenAppLock, ) + SettingsRow( + title = ExportCopy.ROW_TITLE, + subtitle = ExportCopy.ROW_SUBTITLE, + onClick = onOpenExport, + ) SettingsRow( title = "Delete my data", subtitle = "Erase every period, spotting and prediction record", @@ -179,18 +192,16 @@ private fun ResultDialog(title: String, body: String, onDismiss: () -> Unit) { } /** - * The installed version, read from the package manager rather than a - * `BuildConfig` constant — so it reports what is actually on the device, which - * is the number a person reads out when something is wrong. + * The installed version. + * + * The reading itself moved to `feature/export/ExportMapping.kt` so the exporter + * can call it off the main thread; this is the Compose wrapper over the one + * implementation. Two readers of one fact is how they come to disagree. */ @Composable private fun versionName(): String { val context = LocalContext.current - return remember(context) { - runCatching { - context.packageManager.getPackageInfo(context.packageName, 0).versionName - }.getOrNull() ?: "unknown" - } + return remember(context) { installedVersionName(context) } } /** @@ -326,5 +337,5 @@ private fun StaticRow(title: String, value: String) { ) @Composable private fun PreviewSettingsRoot() = PeriodTheme { - SettingsScreen(onOpenNotifications = {}, onOpenAppLock = {}, viewModel = null) + SettingsScreen(onOpenNotifications = {}, onOpenAppLock = {}, onOpenExport = {}, viewModel = null) } diff --git a/app/src/main/kotlin/dev/privacyllc/period/navigation/PeriodApp.kt b/app/src/main/kotlin/dev/privacyllc/period/navigation/PeriodApp.kt index 6234ed0..61345fe 100644 --- a/app/src/main/kotlin/dev/privacyllc/period/navigation/PeriodApp.kt +++ b/app/src/main/kotlin/dev/privacyllc/period/navigation/PeriodApp.kt @@ -37,6 +37,8 @@ import androidx.navigation.compose.rememberNavController import dev.privacyllc.period.R import dev.privacyllc.period.designsystem.PeriodTheme import dev.privacyllc.period.feature.calendar.CalendarScreen +import dev.privacyllc.period.feature.export.ExportHost +import dev.privacyllc.period.feature.export.ExportScreen import dev.privacyllc.period.feature.insights.InsightsScreen import dev.privacyllc.period.feature.lock.LockSettingsScreen import dev.privacyllc.period.feature.onboarding.OnboardingScreen @@ -91,6 +93,8 @@ fun PeriodApp() { val backStackEntry by navController.currentBackStackEntryAsState() val currentDestination = backStackEntry?.destination + ExportHost() + Scaffold( bottomBar = { NavigationBar { @@ -144,10 +148,12 @@ fun PeriodApp() { SettingsScreen( onOpenNotifications = { navController.navigate(SETTINGS_NOTIFICATIONS) }, onOpenAppLock = { navController.navigate(SETTINGS_LOCK) }, + onOpenExport = { navController.navigate(SETTINGS_EXPORT) }, ) } composable(SETTINGS_NOTIFICATIONS) { NotificationSettingsScreen() } composable(SETTINGS_LOCK) { LockSettingsScreen() } + composable(SETTINGS_EXPORT) { ExportScreen() } } } } @@ -189,8 +195,19 @@ private fun PlaceholderPreview() { PeriodTheme { PlaceholderScreen("Calendar") } } +/** + * The export destination is written here, not on the export screen. + * + * Everything inside `PeriodApp` composes only after the gate has unlocked, so + * this is the earliest point at which writing is allowed — and the export + * screen itself may be long gone, because returning from the save dialog can + * re-lock the app and the user unlocks onto Today. + */ /** Reminder settings, a child of the Settings tab rather than a fifth tab. */ private const val SETTINGS_NOTIFICATIONS = "settings/notifications" /** §36 puts the app lock above Delete My Data: protect first, destroy second. */ private const val SETTINGS_LOCK = "settings/lock" + +/** Export, above Delete in §36's order: the offer of a copy comes before the erase. */ +private const val SETTINGS_EXPORT = "settings/export" diff --git a/app/src/test/kotlin/dev/privacyllc/period/feature/export/DataExporterTest.kt b/app/src/test/kotlin/dev/privacyllc/period/feature/export/DataExporterTest.kt new file mode 100644 index 0000000..c686d10 --- /dev/null +++ b/app/src/test/kotlin/dev/privacyllc/period/feature/export/DataExporterTest.kt @@ -0,0 +1,177 @@ +package dev.privacyllc.period.feature.export + +import android.content.Context +import android.net.Uri +import androidx.activity.result.contract.ActivityResultContracts +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.preferencesDataStoreFile +import androidx.test.core.app.ApplicationProvider +import dev.privacyllc.period.core.data.CycleData +import dev.privacyllc.period.core.data.CycleRepository +import dev.privacyllc.period.core.datastore.UserPreferencesRepository +import dev.privacyllc.period.core.notifications.NotificationCopy +import dev.privacyllc.period.domain.prediction.PersonalPredictionEngine +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows +import org.robolectric.annotation.Config +import java.io.ByteArrayOutputStream +import java.io.OutputStream +import java.time.Clock +import java.time.LocalDate +import java.time.ZoneOffset + +/** + * The export leaves nothing behind, and it writes where the user pointed. + * + * The format itself is pinned in `:core:export` against a golden file. What is + * checked here is the part that only exists on Android: that the destination is + * a document the *user* chose, that the bytes reach it, and — the clause from + * `#35`'s own verify line — that no copy is left in app-private storage. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class DataExporterTest { + + private val context = ApplicationProvider.getApplicationContext() + private val today = LocalDate.of(2026, 8, 19) + private val clock = Clock.fixed(today.atStartOfDay(ZoneOffset.UTC).toInstant(), ZoneOffset.UTC) + + private lateinit var repo: CycleRepository + private lateinit var prefs: UserPreferencesRepository + private lateinit var controller: ExportController + private lateinit var exporter: DataExporter + + private val destination: Uri = Uri.parse("content://test.documents/document/42") + + @Before fun setUp() { + repo = CycleData.repository(context, PersonalPredictionEngine(), clock) + runBlocking { repo.deleteAllHealthData() } + prefs = UserPreferencesRepository( + PreferenceDataStoreFactory.create { + context.preferencesDataStoreFile("export_test_${System.nanoTime()}") + }, + ) + controller = ExportController() + exporter = DataExporter(repo, prefs, controller, clock) + context.cacheDir.deleteRecursively() + context.cacheDir.mkdirs() + } + + private fun await(predicate: () -> Boolean) = runBlocking { + withTimeout(10_000) { while (!predicate()) delay(20) } + } + + private fun registerStream(stream: OutputStream) { + Shadows.shadowOf(context.contentResolver).registerOutputStream(destination, stream) + } + + /** + * The user chose the destination, and that is a property of the *intent*. + * + * Pinned so nobody can swap in `ACTION_SEND`, which would hand the file to + * an app of the user's choosing rather than write it to a place of their + * choosing — and would need a temporary copy to hand over. + */ + @Test fun `the picker asks for a document the user names`() { + val name = ExportCopy.fileName(today) + val intent = ActivityResultContracts.CreateDocument("application/json") + .createIntent(context, name) + + assertEquals(android.content.Intent.ACTION_CREATE_DOCUMENT, intent.action) + assertEquals("application/json", intent.type) + assertEquals(name, intent.getStringExtra(android.content.Intent.EXTRA_TITLE)) + } + + @Test fun `the file name says nothing about what the app is for`() { + val name = ExportCopy.fileName(today) + val leaks = NotificationCopy.SENSITIVE_WORDS.filter { name.contains(it, ignoreCase = true) } + assertEquals("the file name is visible in pickers and sync notifications", emptyList(), leaks) + assertTrue(name.endsWith(".json")) + } + + @Test fun `the export reaches the stream the resolver hands back`() { + runBlocking { repo.confirmPeriodStart(LocalDate.of(2026, 7, 16)) } + val sink = ByteArrayOutputStream() + registerStream(sink) + + exporter.export(context, destination) + await { controller.outcome.value == ExportOutcome.DONE } + + val written = sink.toString(Charsets.UTF_8.name()) + assertTrue("no document was written", written.contains("\"format\"")) + assertTrue("the user's record is missing", written.contains("2026-07-16")) + assertTrue("a derived value leaked into the file", !written.contains("confidence")) + } + + /** + * `#35`'s verify line, made mechanical: *"with no copy left in app-private + * or external storage afterwards"*. + * + * This is the cheapest possible proof, and it fails the moment anybody + * reintroduces the write-then-share pattern, because that needs a real file + * in `cacheDir`. + */ + @Test fun `no copy is left anywhere in app-private storage`() { + runBlocking { repo.confirmPeriodStart(LocalDate.of(2026, 7, 16)) } + registerStream(ByteArrayOutputStream()) + + exporter.export(context, destination) + await { controller.outcome.value == ExportOutcome.DONE } + + val leftovers = context.cacheDir.walkTopDown().filter { it.isFile }.toList() + assertEquals("the export left a temporary file behind", emptyList(), leftovers) + + val inFiles = context.filesDir.walkTopDown() + .filter { it.isFile && it.name.contains("records") } + .toList() + assertEquals(emptyList(), inFiles) + } + + /** + * A stream that dies partway must not be reported as a finished export. + * "Saved" over a truncated file is the worst outcome this feature has. + */ + @Test fun `a failing stream is reported as a failure, never as done`() { + runBlocking { repo.confirmPeriodStart(LocalDate.of(2026, 7, 16)) } + registerStream(object : OutputStream() { + override fun write(b: Int) = throw java.io.IOException("disk full") + override fun write(b: ByteArray, off: Int, len: Int) = throw java.io.IOException("disk full") + }) + + exporter.export(context, destination) + await { controller.outcome.value != ExportOutcome.WRITING } + + assertFalse( + "a failed write was reported as a completed export", + controller.outcome.value == ExportOutcome.DONE, + ) + } + + /** + * The destination is consumed once. + * + * A relock/unlock cycle re-enters the branch that drains it, and writing a + * second time into a document the user already received would change a file + * they believed was finished. + */ + @Test fun `a destination is taken once and then gone`() { + controller.onDestination(destination) + assertEquals(destination, controller.takeDestination()) + assertEquals(null, controller.takeDestination()) + } + + @Test fun `cancelling the picker says nothing at all`() { + controller.onDestination(null) + assertEquals(ExportOutcome.IDLE, controller.outcome.value) + assertEquals(null, controller.takeDestination()) + } +} diff --git a/build.gradle.kts b/build.gradle.kts index 58a7f3a..b93b549 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -44,7 +44,7 @@ plugins { val allowedProjectDependencies: Map> = mapOf( ":app" to setOf( ":core:designsystem", ":core:data", ":core:datastore", ":core:notifications", - ":core:security", ":domain:cycle", ":domain:prediction", + ":core:security", ":core:export", ":domain:cycle", ":domain:prediction", ), ":core:designsystem" to emptySet(), ":core:database" to setOf(":domain:cycle", ":domain:prediction"), @@ -56,6 +56,12 @@ val allowedProjectDependencies: Map> = mapOf( // module that can see a cycle date, and the erase path deliberately runs in // :app so that never has to happen. ":core:security" to emptySet(), + // Only :domain:cycle, and the omission is the rule. An export must contain + // "only the user's own data — no derived analytics": not depending on + // :domain:prediction puts Prediction, PredictionAccuracy, FertilityEstimate + // and CycleRecord off this module's classpath entirely, so adding one is a + // compile error rather than something review has to catch. + ":core:export" to setOf(":domain:cycle"), ":domain:cycle" to emptySet(), ":domain:prediction" to setOf(":domain:cycle"), // Batch 07. Empty, and that is the whole point: the ads module may reach @@ -65,7 +71,7 @@ val allowedProjectDependencies: Map> = mapOf( ) /** Modules that must never see the Android SDK, by never applying an Android plugin. */ -val mustStayPureJvm = setOf(":domain:cycle", ":domain:prediction") +val mustStayPureJvm = setOf(":domain:cycle", ":domain:prediction", ":core:export") /** * Configurations that describe what SHIPS. Test-only dependencies are not a @@ -92,6 +98,9 @@ val observedProjectDependencies = mutableMapOf>() val observedAndroidPlugins = mutableMapOf>() val containerProjects = mutableSetOf() +/** Directory of every real module, relative to the root, filled in `afterEvaluate`. */ +val observedModuleDirs = mutableSetOf() + subprojects { afterEvaluate { if (!buildFile.exists()) { @@ -102,6 +111,7 @@ subprojects { containerProjects += path return@afterEvaluate } + observedModuleDirs += projectDir.relativeTo(rootDir).invariantSeparatorsPath observedProjectDependencies[path] = configurations .filter { it.name in shippingConfigurations } .flatMap { conf -> conf.dependencies.filterIsInstance() } @@ -374,10 +384,24 @@ tasks.register("checkPermissions") { // A guard that punishes the clearest possible explanation gets the explanation // deleted, which is a worse outcome than no guard. -/** Modules that can see a cycle date. `core/designsystem` cannot, so it is absent. */ +/** + * Modules that provably cannot see a cycle date, each with the reason. + * + * Promoted from a comment to a declaration, because the two lists together are + * what makes the check below complete. A module in neither is not "assumed + * fine" — it is unmeasured, and that is reported. + */ +val modulesWithNoHealthData: Map = mapOf( + "core/designsystem" to + "colour and type tokens only — it depends on nothing in this project, " + + "so no cycle type is even on its classpath", +) + +/** Modules that can see a cycle date. */ val modulesSeeingHealthData: List = listOf( "app", "core/data", "core/database", "core/datastore", - "core/notifications", "core/security", "domain/cycle", "domain/prediction", + "core/notifications", "core/security", "core/export", + "domain/cycle", "domain/prediction", ) /** @@ -398,6 +422,11 @@ tasks.register("checkNoHealthLogging") { val roots = modulesSeeingHealthData.map { layout.projectDirectory.dir(it).asFile } val forbidden = forbiddenLoggingCalls + // Read in doLast by reference, filled by the afterEvaluate block above — + // the same arrangement checkModuleBoundaries uses for its observations. + val allModules = observedModuleDirs + val scanned0 = modulesSeeingHealthData.toSet() + val exemptModules = modulesWithNoHealthData // Resolved at configuration time. Reaching for `layout` inside doLast // captures the Project itself, which the configuration cache refuses to // serialize — the build fails with a cache problem rather than a guard @@ -441,6 +470,28 @@ tasks.register("checkNoHealthLogging") { } } + // The hole this closes is the one this project has documented twice and + // been unable to check: a module missing from modulesSeeingHealthData is + // not scanned, and an unscanned module looks exactly like a clean one. + // `app/proguard-rules.pro` describes it — "somebody adding a module and + // forgetting to list it gets no warning" — and both :core:security and + // :core:export would have shipped key material and a serializer of raw + // cycle dates through it. + // + // So every module must be in one list or the other, and being in + // neither is a violation rather than an exemption. + val undeclared = (allModules - scanned0 - exemptModules.keys).sorted() + if (undeclared.isNotEmpty()) { + logger.error("") + logger.error("These modules are in neither list, so nothing checked them:") + undeclared.forEach { logger.error(" - $it is in neither modulesSeeingHealthData nor modulesWithNoHealthData") } + logger.error("") + logger.error("A module that can see a cycle date belongs in the first. One that") + logger.error("provably cannot belongs in the second, WITH ITS REASON. Absence from") + logger.error("both is not an exemption — it is a module nobody measured.") + throw GradleException("${undeclared.size} module(s) declared in neither logging list.") + } + // Never a silent pass. A path typo in modulesSeeingHealthData would // otherwise report a clean build having read nothing at all, which is // how the module-boundary guard spent its first day green. @@ -465,7 +516,10 @@ tasks.register("checkNoHealthLogging") { throw GradleException("${hits.size} logging call(s) where health data is visible.") } - logger.lifecycle("health logging: $scanned Kotlin file(s) checked, no logging calls.") + logger.lifecycle( + "health logging: $scanned Kotlin file(s) across ${scanned0.size} module(s) checked, " + + "${exemptModules.size} declared unable to see health data, no logging calls.", + ) } } @@ -607,6 +661,94 @@ tasks.register("checkThemedDrawables") { } } +// --------------------------------------------------------------------------- +// Health data never reaches shared storage — PRODUCT_PLAN.md §45 +// --------------------------------------------------------------------------- +// +// §45 forbids writing health history to shared external storage, and until now +// that rule was enforced by nobody having typed it. `checkPermissions` cannot +// see this: it matches `` only, so a `` declaring +// androidx's FileProvider merges green — and FileProvider is already on the +// classpath through core-ktx, so adding one is a manifest entry away. +// +// The pattern this exists to stop is the obvious way to build an export: write +// the file to Downloads or to cacheDir, then hand somebody a path. #35 names it +// directly — "writing to Downloads/ and then sharing a path is exactly the +// pattern that rule exists to prevent". The Storage Access Framework needs none +// of these calls, so their absence is checkable. + +val forbiddenStorageCalls: List = listOf( + "Environment.getExternalStorage", + "Environment.DIRECTORY_", + "getExternalFilesDir", + "getExternalCacheDir", + "MediaStore.Downloads", + "MediaStore.Files", + "FileProvider", + "Intent.ACTION_SEND", + "ACTION_SEND_MULTIPLE", +) + +tasks.register("checkNoSharedStorageWrites") { + group = "verification" + description = "No module that can see a cycle date may write to shared storage (§45)." + + val roots = modulesSeeingHealthData.map { layout.projectDirectory.dir(it).asFile } + val forbidden = forbiddenStorageCalls + val repoRoot = layout.projectDirectory.asFile + + doLast { + // Comments first, for the reason GUARDS.md §2 gives: the KDoc on + // DataExporter explains why FileProvider and ACTION_SEND are absent, and + // a naive scan would report the explanation as the violation. + fun codeOf(text: String): String = + text.replace(Regex("""/\*.*?\*/""", RegexOption.DOT_MATCHES_ALL), "") + .lines().joinToString("\n") { it.substringBefore("//") } + + var scanned = 0 + val hits = mutableListOf() + + roots.forEach { root -> + root.walkTopDown() + .filter { it.isFile && it.extension == "kt" } + .filterNot { it.path.contains("/build/") || it.path.contains("/bin/") } + // Test sources may name these to assert they are NOT used. + .filterNot { it.path.contains("/src/test/") || it.path.contains("/src/androidTest/") } + .forEach { file -> + scanned++ + codeOf(file.readText()).lines().forEachIndexed { i, line -> + forbidden.forEach { pattern -> + if (line.contains(pattern)) { + hits += "${file.relativeTo(repoRoot)}:${i + 1} $pattern" + } + } + } + } + } + + if (scanned == 0) { + throw GradleException( + "no Kotlin sources found, so no storage call was checked. This is not a pass.", + ) + } + + if (hits.isNotEmpty()) { + logger.error("") + logger.error("PRODUCT_PLAN.md §45: health data must never reach shared storage.") + logger.error("") + hits.forEach { logger.error(" - $it") } + logger.error("") + logger.error("A file written to Downloads, or shared by path through a FileProvider,") + logger.error("has left this app's sandbox and is readable by anything with storage") + logger.error("access. The Storage Access Framework needs none of these: the user") + logger.error("picks a document and the app writes into it, with no copy in between.") + throw GradleException("${hits.size} shared-storage call(s) where health data is visible.") + } + + logger.lifecycle("shared storage: $scanned Kotlin file(s) checked, no shared-storage writes.") + } +} + // Wired into `check` so it runs with the tests rather than only when remembered. subprojects { tasks.matching { it.name == "check" }.configureEach { @@ -614,5 +756,6 @@ subprojects { if (project.path == ":app") dependsOn(rootProject.tasks.named("checkPermissions")) dependsOn(rootProject.tasks.named("checkNoHealthLogging")) dependsOn(rootProject.tasks.named("checkThemedDrawables")) + dependsOn(rootProject.tasks.named("checkNoSharedStorageWrites")) } } diff --git a/core/export/build.gradle.kts b/core/export/build.gradle.kts new file mode 100644 index 0000000..c01951b --- /dev/null +++ b/core/export/build.gradle.kts @@ -0,0 +1,22 @@ +plugins { + alias(libs.plugins.kotlin.jvm) +} + +// Pure JVM, and that is the point rather than a convenience. +// +// The issue's hardest requirement is "only the user's own data — no derived +// analytics, no diagnostic payload, nothing about the device". Depending on +// `:domain:cycle` alone makes that a COMPILE ERROR instead of a review comment: +// `Prediction`, `PredictionAccuracy`, `FertilityEstimate` and `CycleRecord` live +// in `:domain:prediction` and are simply not on this classpath, and being +// kotlin("jvm") puts `android.os.Build` off it too, so a device fact cannot be +// added by accident. +kotlin { + jvmToolchain(21) +} + +dependencies { + implementation(project(":domain:cycle")) + + testImplementation(libs.junit) +} diff --git a/core/export/src/main/kotlin/dev/privacyllc/period/core/export/ExportDocument.kt b/core/export/src/main/kotlin/dev/privacyllc/period/core/export/ExportDocument.kt new file mode 100644 index 0000000..ff9c760 --- /dev/null +++ b/core/export/src/main/kotlin/dev/privacyllc/period/core/export/ExportDocument.kt @@ -0,0 +1,185 @@ +package dev.privacyllc.period.core.export + +import dev.privacyllc.period.domain.cycle.PeriodRecord +import dev.privacyllc.period.domain.cycle.SpottingRecord +import java.time.LocalDate +import java.time.LocalTime +import java.time.format.DateTimeFormatter + +/** + * The settings that are the user's own, as this module needs them. + * + * Its own type rather than `UserPreferences`, deliberately. Reaching for that + * class would put `core/datastore` on this module's classpath and make the + * export inherit every field anybody ever adds to it — including fields that + * must never leave, like the Play entitlement. Mapping is explicit and total, + * in `:app`, so adding a preference is a compile error here until somebody + * decides whether it belongs in an archive. + */ +data class ExportedSettings( + val notificationPrivacy: String, + val reminderTime: LocalTime, + val periodApproaching: Boolean, + val periodExpectedToday: Boolean, + val didItStart: Boolean, + val periodEndCheckIn: Boolean, + val fertileWindow: Boolean, + val ovulation: Boolean, + val biometricUnlock: Boolean, + val theme: String, +) { + /** + * Dateless and valueless, for the reason `PeriodRecord.toString` gives: a + * data class renders its own contents into any exception message that + * interpolates it, and `checkNoHealthLogging` cannot see that happen. + * A reminder time is a fact about the user's day. + */ + override fun toString(): String = "ExportedSettings(privacy=$notificationPrivacy)" +} + +/** + * The file the user gets, and the decision that outlives this batch. + * + * Whatever ships first is what people's archives are in, so the shape is a + * contract rather than an implementation detail. It is pinned byte-for-byte by + * `ExportFormatGoldenTest` against a committed golden file, which doubles as + * the documented example — `SECURITY.md` links at it rather than pasting a + * second copy that can drift. + * + * ## The contract + * + * 1. `format` is a magic string. Anything else is somebody else's JSON, and a + * future importer refuses it rather than guessing. + * 2. `formatVersion` is an integer, and readers must ignore unknown keys — so + * keys may be **added** freely. Removing one, retyping one, or changing what + * one means requires a version bump. + * 3. Every key is always present. Missing information is `null`, never an + * absent key, so a reader never has to distinguish "not recorded" from + * "written by an older version". + * 4. Arrays ascend by their natural key, matching the DAO ordering, so two + * exports diff cleanly and a person can see they lost nothing. + * 5. Natural keys, not row ids. Both tables carry a UNIQUE index on their date, + * so ids add nothing — and autoGenerate ids are monotonic, so gaps would + * disclose how many records the user **deleted**. + * 6. Enums travel as their Kotlin `name`, uppercase. + * 7. Keys are English and never localised. Only the `aboutThisFile` prose may + * ever be translated. + * 8. Dates are `uuuu-MM-dd` with **no timezone and no conversion, ever**. + * + * ## Why the JSON is written by hand + * + * Because nothing free-form reaches it. Dates are formatted, enums are `name`s, + * booleans are booleans, and [requireSafe] refuses any value outside a narrow + * character set — so there is no string to escape and no escaping bug to have. + * That is a stronger guarantee than a serializer would give, and it costs no + * dependency in a project that adds them reluctantly. + * + * `print(` and `println(` are in `checkNoHealthLogging`'s forbidden list as + * substrings, so `PrintWriter.print`/`println` fail the build. Nothing here + * needs them; the file is built as a string and handed back. + */ +object ExportDocument { + + const val FORMAT = "privacy-period-tracker-export" + const val FORMAT_VERSION = 1 + + private val DATE = DateTimeFormatter.ISO_LOCAL_DATE + private val TIME = DateTimeFormatter.ofPattern("HH:mm") + + /** + * The only characters any emitted value may contain. + * + * Every value that is not a date, a boolean or an integer passes through + * here. An enum constant renamed to something with a quote or a backslash + * in it would otherwise produce a file that is not JSON, and the failure + * would appear in the user's archive rather than in a test. + */ + private val SAFE = Regex("""^[A-Za-z0-9_:.\-]+$""") + + private fun requireSafe(value: String): String { + require(SAFE.matches(value)) { "a value outside the safe character set reached the export" } + return value + } + + /** + * What the file says about itself, in the second person. + * + * Prose explains; it never restates a value that also appears as data, so + * the two halves cannot disagree. The line about encryption is the one that + * has to be there: this file leaves the app's sandbox and the app lock has + * no reach over it. + */ + private val ABOUT = listOf( + "This is your own record, exported from the period tracker you use on your phone.", + "It holds every period and spotting entry you logged, and your settings. " + + "Nothing else: no predictions, no averages, nothing about your phone, " + + "and nothing that was ever sent anywhere.", + "A date like 2026-07-16 is a calendar date, exactly as you entered it. " + + "It carries no time zone and is never shifted, wherever in the world you open this file.", + "Your PIN is not in this file and cannot be.", + "This file is not encrypted. Anyone who opens it can read it, so keep it somewhere you trust.", + "Keep this file. A future version of the app will be able to read it back.", + ) + + fun render( + periods: List, + spotting: List, + settings: ExportedSettings, + appVersion: String, + exportedOn: LocalDate, + ): String { + val b = StringBuilder() + b.append("{\n") + b.append(""" "format": "${requireSafe(FORMAT)}",""").append('\n') + b.append(""" "formatVersion": $FORMAT_VERSION,""").append('\n') + + b.append(""" "aboutThisFile": [""").append('\n') + ABOUT.forEachIndexed { i, line -> + b.append(""" "$line"""").append(if (i == ABOUT.lastIndex) "\n" else ",\n") + } + b.append(" ],\n") + + b.append(""" "exportedOn": "${exportedOn.format(DATE)}",""").append('\n') + b.append(""" "appVersion": "${requireSafe(appVersion)}",""").append('\n') + + // Rule 4: ascending by natural key, matching the DAO's ORDER BY. + b.append(""" "periods": [""").append('\n') + val sortedPeriods = periods.sortedBy { it.startDate } + sortedPeriods.forEachIndexed { i, p -> + b.append(" {\n") + b.append(""" "startDate": "${p.startDate.format(DATE)}",""").append('\n') + b.append(""" "endDate": ${p.endDate?.let { "\"${it.format(DATE)}\"" } ?: "null"},""").append('\n') + b.append(""" "source": "${requireSafe(p.source.name)}",""").append('\n') + b.append(""" "confirmed": ${p.isConfirmed}""").append('\n') + b.append(" }").append(if (i == sortedPeriods.lastIndex) "\n" else ",\n") + } + b.append(" ],\n") + + b.append(""" "spotting": [""").append('\n') + val sortedSpotting = spotting.sortedBy { it.date } + sortedSpotting.forEachIndexed { i, s -> + b.append(" {\n") + b.append(""" "date": "${s.date.format(DATE)}"""").append('\n') + b.append(" }").append(if (i == sortedSpotting.lastIndex) "\n" else ",\n") + } + b.append(" ],\n") + + b.append(""" "settings": {""").append('\n') + b.append(""" "notificationPrivacy": "${requireSafe(settings.notificationPrivacy)}",""").append('\n') + b.append(""" "reminderTime": "${settings.reminderTime.format(TIME)}",""").append('\n') + b.append(""" "reminders": {""").append('\n') + b.append(""" "periodApproaching": ${settings.periodApproaching},""").append('\n') + b.append(""" "periodExpectedToday": ${settings.periodExpectedToday},""").append('\n') + b.append(""" "didItStart": ${settings.didItStart},""").append('\n') + b.append(""" "periodEndCheckIn": ${settings.periodEndCheckIn},""").append('\n') + b.append(""" "fertileWindow": ${settings.fertileWindow},""").append('\n') + b.append(""" "ovulation": ${settings.ovulation}""").append('\n') + b.append(" },\n") + b.append(""" "biometricUnlock": ${settings.biometricUnlock},""").append('\n') + b.append(""" "theme": "${requireSafe(settings.theme)}"""").append('\n') + b.append(" }\n") + + b.append("}\n") + return b.toString() + } +} diff --git a/core/export/src/test/kotlin/dev/privacyllc/period/core/export/ExportFixture.kt b/core/export/src/test/kotlin/dev/privacyllc/period/core/export/ExportFixture.kt new file mode 100644 index 0000000..badfc39 --- /dev/null +++ b/core/export/src/test/kotlin/dev/privacyllc/period/core/export/ExportFixture.kt @@ -0,0 +1,78 @@ +package dev.privacyllc.period.core.export + +import dev.privacyllc.period.domain.cycle.PeriodRecord +import dev.privacyllc.period.domain.cycle.PeriodRecordSource +import dev.privacyllc.period.domain.cycle.SpottingRecord +import java.time.LocalDate +import java.time.LocalTime + +/** + * One fixture, shared by every test that renders. + * + * Deliberately covers the shapes that break serializers: a null `endDate`, more + * than one enum constant, an unconfirmed row, and records supplied out of order + * so the sort in [ExportDocument] is exercised rather than assumed. + */ +internal object ExportFixture { + + val periods: List = listOf( + // Out of order on purpose — rule 4 says the file ascends. + PeriodRecord( + id = 3, + startDate = LocalDate.of(2026, 6, 19), + endDate = LocalDate.of(2026, 6, 24), + source = PeriodRecordSource.NOTIFICATION_CONFIRMATION, + isConfirmed = true, + ), + PeriodRecord( + id = 1, + startDate = LocalDate.of(2026, 4, 22), + endDate = LocalDate.of(2026, 4, 27), + source = PeriodRecordSource.HISTORICAL_ENTRY, + isConfirmed = true, + ), + PeriodRecord( + id = 4, + startDate = LocalDate.of(2026, 7, 16), + endDate = null, + source = PeriodRecordSource.EDITED, + isConfirmed = false, + ), + PeriodRecord( + id = 2, + startDate = LocalDate.of(2026, 5, 21), + endDate = LocalDate.of(2026, 5, 25), + source = PeriodRecordSource.MANUAL, + isConfirmed = true, + ), + ) + + val spotting: List = listOf( + SpottingRecord(id = 2, date = LocalDate.of(2026, 7, 13)), + SpottingRecord(id = 1, date = LocalDate.of(2026, 5, 16)), + ) + + val settings = ExportedSettings( + notificationPrivacy = "DISCREET", + reminderTime = LocalTime.of(10, 0), + periodApproaching = true, + periodExpectedToday = true, + didItStart = true, + periodEndCheckIn = false, + fertileWindow = false, + ovulation = false, + biometricUnlock = true, + theme = "SYSTEM", + ) + + const val APP_VERSION = "0.1.0" + val EXPORTED_ON: LocalDate = LocalDate.of(2026, 8, 19) + + fun render(): String = ExportDocument.render( + periods = periods, + spotting = spotting, + settings = settings, + appVersion = APP_VERSION, + exportedOn = EXPORTED_ON, + ) +} diff --git a/core/export/src/test/kotlin/dev/privacyllc/period/core/export/ExportFormatTest.kt b/core/export/src/test/kotlin/dev/privacyllc/period/core/export/ExportFormatTest.kt new file mode 100644 index 0000000..8499d03 --- /dev/null +++ b/core/export/src/test/kotlin/dev/privacyllc/period/core/export/ExportFormatTest.kt @@ -0,0 +1,168 @@ +package dev.privacyllc.period.core.export + +import dev.privacyllc.period.domain.cycle.PeriodRecordSource +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.time.LocalDate +import java.util.TimeZone + +/** + * The format is a contract, so it is pinned rather than described. + * + * `#35` says the file format "outlives this batch: whatever ships first is what + * people's archives will be in". The strongest protection available for that is + * a committed golden file compared byte for byte — a reformat, a reordered key + * or a changed date rendering all fail here rather than in somebody's archive + * two years from now. + * + * The golden doubles as the documented example, so `SECURITY.md` links at it + * instead of pasting a second copy that would drift. + */ +class ExportFormatTest { + + private fun golden(): String = + checkNotNull(javaClass.classLoader.getResourceAsStream("golden-v1.json")) { + "golden-v1.json is missing — the format has nothing pinning it" + }.readBytes().decodeToString() + + @Test fun `the rendered file matches the golden byte for byte`() { + assertEquals(golden(), ExportFixture.render()) + } + + /** + * The off-by-one that would never show up where it was written. + * + * `Converters.kt` stores a `LocalDate` as its epoch day precisely so it + * "cannot carry a timezone by accident". Any zone-aware formatting in the + * writer would shift every date for users east or west of the developer, + * and a cycle tracker off by one day is wrong in the way that matters. + */ + @Test fun `it renders identically in every time zone`() { + val original = TimeZone.getDefault() + try { + val outputs = listOf("UTC", "Pacific/Kiritimati", "Etc/GMT+12").map { zone -> + TimeZone.setDefault(TimeZone.getTimeZone(zone)) + zone to ExportFixture.render() + } + outputs.forEach { (zone, rendered) -> + assertEquals("the file changed when the device was in $zone", golden(), rendered) + } + } finally { + TimeZone.setDefault(original) + } + } + + /** + * The whole key set, asserted as a set rather than checked for presence. + * + * `assertEquals` and not `contains`, because the claim being defended is + * "only the user's own data" — and a `contains` check passes happily when a + * new key appears. Every addition has to be a deliberate edit here. + */ + @Test fun `the file contains exactly these keys and no others`() { + val keys = Regex("\"([A-Za-z0-9_]+)\"\\s*:").findAll(ExportFixture.render()) + .map { it.groupValues[1] } + .toSet() + + val expected = setOf( + "format", "formatVersion", "aboutThisFile", "exportedOn", "appVersion", + "periods", "startDate", "endDate", "source", "confirmed", + "spotting", "date", + "settings", "notificationPrivacy", "reminderTime", "reminders", + "periodApproaching", "periodExpectedToday", "didItStart", + "periodEndCheckIn", "fertileWindow", "ovulation", + "biometricUnlock", "theme", + ) + assertEquals(expected, keys) + } + + /** + * The exclusions, named individually so a failure says which one came back. + * + * Keys, not substrings: `fertileWindow` and `ovulation` are legitimate + * reminder toggles, and the prose legitimately contains the word + * "predictions" while saying there are none. + */ + @Test fun `nothing derived, nothing about the device, and no row ids`() { + val keys = Regex("\"([A-Za-z0-9_]+)\"\\s*:").findAll(ExportFixture.render()) + .map { it.groupValues[1] } + .toSet() + + val mustNotAppear = listOf( + "id", "predictedDate", "prediction", "predictions", "confidence", + "confidenceScore", "accuracy", "modelVersion", "windowStart", "windowEnd", + "notYet", "notYetAnswers", "device", "model", "manufacturer", "sdk", + "locale", "timeZone", "timezone", "adsRemoved", "onboardingCompleted", + "checkInCount", "createdAt", "updatedAt", "recordedAt", "pin", "verifier", + ) + val found = mustNotAppear.filter { it in keys } + assertEquals("a key that must never be exported is in the file", emptyList(), found) + } + + /** + * Every enum constant survives the safe-character check. + * + * The renderer emits enum `name`s directly and escapes nothing, which is + * safe exactly while no value can contain a quote or a backslash. Renaming + * a constant to something exotic would otherwise produce a file that is not + * JSON — in the user's archive, not in a build. + * + * Only `PeriodRecordSource` can be checked here: `NotificationPrivacy` and + * `AppTheme` live in `core/datastore`, which is deliberately not on this + * module's classpath. The mapping test in `:app` covers those. + */ + @Test fun `every period source constant is safe to write unescaped`() { + PeriodRecordSource.entries.forEach { source -> + val rendered = ExportDocument.render( + periods = listOf(ExportFixture.periods.first().copy(source = source)), + spotting = emptyList(), + settings = ExportFixture.settings, + appVersion = ExportFixture.APP_VERSION, + exportedOn = ExportFixture.EXPORTED_ON, + ) + assertTrue("$source did not reach the file", rendered.contains("\"${source.name}\"")) + } + } + + @Test fun `a value outside the safe set is refused rather than written`() { + val thrown = runCatching { + ExportDocument.render( + periods = emptyList(), + spotting = emptyList(), + settings = ExportFixture.settings.copy(theme = """SYS"TEM"""), + appVersion = ExportFixture.APP_VERSION, + exportedOn = ExportFixture.EXPORTED_ON, + ) + }.exceptionOrNull() + + assertTrue( + "a quote in a value must not reach the file", + thrown is IllegalArgumentException, + ) + // The message must not carry the offending value: it could be anything, + // and this exception is exactly what a crash reporter collects. + assertTrue( + "the refusal leaked the value it refused: ${thrown?.message}", + thrown?.message?.contains("SYS") != true, + ) + } + + /** + * A user who exports before recording anything gets a real file, not a + * crash and not an empty one — and every key is still present, per rule 3. + */ + @Test fun `empty history still produces a valid file`() { + val rendered = ExportDocument.render( + periods = emptyList(), + spotting = emptyList(), + settings = ExportFixture.settings, + appVersion = ExportFixture.APP_VERSION, + exportedOn = LocalDate.of(2026, 1, 1), + ) + assertTrue(rendered.contains("\"periods\": [\n ]")) + assertTrue(rendered.contains("\"spotting\": [\n ]")) + assertTrue(rendered.contains("\"format\"")) + assertTrue(rendered.trimEnd().endsWith("}")) + } +} diff --git a/core/export/src/test/resources/golden-v1.json b/core/export/src/test/resources/golden-v1.json new file mode 100644 index 0000000..6039e13 --- /dev/null +++ b/core/export/src/test/resources/golden-v1.json @@ -0,0 +1,62 @@ +{ + "format": "privacy-period-tracker-export", + "formatVersion": 1, + "aboutThisFile": [ + "This is your own record, exported from the period tracker you use on your phone.", + "It holds every period and spotting entry you logged, and your settings. Nothing else: no predictions, no averages, nothing about your phone, and nothing that was ever sent anywhere.", + "A date like 2026-07-16 is a calendar date, exactly as you entered it. It carries no time zone and is never shifted, wherever in the world you open this file.", + "Your PIN is not in this file and cannot be.", + "This file is not encrypted. Anyone who opens it can read it, so keep it somewhere you trust.", + "Keep this file. A future version of the app will be able to read it back." + ], + "exportedOn": "2026-08-19", + "appVersion": "0.1.0", + "periods": [ + { + "startDate": "2026-04-22", + "endDate": "2026-04-27", + "source": "HISTORICAL_ENTRY", + "confirmed": true + }, + { + "startDate": "2026-05-21", + "endDate": "2026-05-25", + "source": "MANUAL", + "confirmed": true + }, + { + "startDate": "2026-06-19", + "endDate": "2026-06-24", + "source": "NOTIFICATION_CONFIRMATION", + "confirmed": true + }, + { + "startDate": "2026-07-16", + "endDate": null, + "source": "EDITED", + "confirmed": false + } + ], + "spotting": [ + { + "date": "2026-05-16" + }, + { + "date": "2026-07-13" + } + ], + "settings": { + "notificationPrivacy": "DISCREET", + "reminderTime": "10:00", + "reminders": { + "periodApproaching": true, + "periodExpectedToday": true, + "didItStart": true, + "periodEndCheckIn": false, + "fertileWindow": false, + "ovulation": false + }, + "biometricUnlock": true, + "theme": "SYSTEM" + } +} diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 4809273..c978da2 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -31,7 +31,7 @@ function calls. Nothing below the ViewModel knows Compose exists. ## Modules -Nine today — a module created before it has contents is a place +Ten today — a module created before it has contents is a place for things to be put by accident. The wider layout sketched in [`../planning/PRODUCT_PLAN.md` §9](../planning/PRODUCT_PLAN.md) arrives the same way, with the batch that needs it. @@ -45,6 +45,7 @@ way, with the batch that needs it. | `core/data` | Android library | `CycleRepository`, entity⇄domain mapping, accuracy — the only module that touches a DAO | `core/database`, `domain/cycle`, `domain/prediction` | | `core/notifications` | Android library | reminder copy, the privacy modes, WorkManager scheduling | `core/data`, `core/datastore`, `domain/*` | | `core/security` | Android library | the app lock's PIN verifier, its Keystore key and the lockout policy | **nothing in this project** | +| `core/export` | **Kotlin JVM** | the export file format, and nothing else | `domain/cycle` — deliberately **not** `domain/prediction` | | `domain/cycle` | **Kotlin JVM** | `PeriodRecord`, `SpottingRecord`, `CycleRecord` and the rules over them | nothing | | `domain/prediction` | **Kotlin JVM** | the forecast, the window, confidence, `NotYetObservation` | `domain/cycle` | @@ -89,6 +90,25 @@ silent data loss on update — here, a user's entire cycle history gone with no error and no way back. A missing migration must be a crash in testing rather than a wipe in production. +### Why `core/export` depends on `domain/cycle` and nothing else + +The export must contain "only the user's own data — no derived analytics, no +diagnostic payload, nothing about the device". That is a sentence in an issue, +and sentences are not enforceable — so the module is shaped to make the wrong +thing impossible rather than merely discouraged. + +`Prediction`, `PredictionAccuracy`, `FertilityEstimate` and `CycleRecord` all +live in `domain/prediction`. By depending only on `domain/cycle`, the export +module cannot **name** them: adding a forecast to the file is a compile error, +not something review has to notice. And being `kotlin("jvm")` rather than an +Android library puts `android.os.Build` off the classpath too, so a device fact +cannot be added either. + +It also means the format is testable in milliseconds on the JVM, which is what +lets it be pinned byte-for-byte against a committed golden file — the strongest +protection available for a format that, as #35 puts it, "outlives this batch: +whatever ships first is what people's archives will be in". + ### Why `core/security` depends on nothing It holds key material, and the rule that follows from that is the one worth @@ -418,6 +438,7 @@ menu is. | `scripts/schema-guard.sh` | a Room entity may not change without the version changing with it — asks git, because Room overwrites the export during the build | | `checkNoHealthLogging` (root `build.gradle.kts`) | no logging call may exist in a module that can see a cycle date — §45. Strips comments and matches a call rather than the class, so the `Log.WARN` constant and the KDoc explaining the rule both stay legal | | `checkThemedDrawables` (root `build.gradle.kts`) | every drawable has a `-night` twin of the same name, both directions. A missing night asset fails nothing at runtime — Android falls back to the light one and draws it on a dark screen — so the only other way to notice is to open that screen in that theme. Exemptions are a named map with reasons, not a narrowed scope | +| `checkNoSharedStorageWrites` (root `build.gradle.kts`) | no module that can see a cycle date may name `getExternalFilesDir`, `MediaStore`, `FileProvider` or `ACTION_SEND` — §45's shared-storage ban, which `checkPermissions` structurally cannot see because it matches `` and a `` merges green | | `.githooks/` | pre-commit, commit-msg, post-commit — see [githooks/README.md](githooks/README.md) | ## What does not belong here diff --git a/docs/security/SECURITY.md b/docs/security/SECURITY.md index a45687d..d708aba 100644 --- a/docs/security/SECURITY.md +++ b/docs/security/SECURITY.md @@ -99,6 +99,32 @@ Data Safety section, and never lets health data reach any of them. excluded from backup. - **Delete My Data is irreversible after confirmation** and actually deletes — not a soft flag. +- **Export is the one path by which health data leaves the sandbox, and it is + the user's own hand that sends it.** The file is written straight into a + document chosen through the Storage Access Framework — no copy in `cacheDir`, + no `FileProvider`, no path handed to another app, and nothing left behind + afterwards, which `DataExporterTest` asserts by walking `cacheDir` after a + successful export. `checkNoSharedStorageWrites` fails the build on any module + that can see a cycle date naming `getExternalFilesDir`, `MediaStore`, + `FileProvider` or `ACTION_SEND`. +- **The exported file is plaintext, deliberately.** It is what §4's "your cycle + belongs to you" actually means, and it is the copy that makes a lost Keystore + key survivable rather than final — the condition recorded under *Deliberately + out of scope* for revisiting database encryption. Putting it behind a + passphrase would reproduce precisely the failure that decision avoided: 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 export screen says so before the picker opens — the file is not + encrypted, and the app lock has no reach over it once it has left. +- **The export carries the user's records and nothing else**, and that is + structural rather than reviewed: `core/export` depends only on `domain/cycle`, + so predictions, accuracy figures, fertility estimates and `android.os.Build` + are not on its classpath at all. The key set is asserted with `assertEquals` + rather than `contains`, so a new field is a failing test rather than a silent + addition. Row ids are excluded because they are monotonic and would disclose + how many records the user **deleted**; the Play entitlement is excluded + because a purchase one file-edit away from being granted is a purchase that + will be. - **The declared permission set is a decision, not a build output.** `checkPermissions` in the root `build.gradle.kts` holds the allowed and forbidden sets and fails on anything else in the merged manifest — the diff --git a/settings.gradle.kts b/settings.gradle.kts index ce23c31..b30f2fc 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -31,5 +31,6 @@ include(":core:datastore") include(":core:data") include(":core:notifications") include(":core:security") +include(":core:export") include(":domain:cycle") include(":domain:prediction")