feat: export my data, as one plaintext file the user places

"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
<uses-permission>, and a <provider> 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) <noreply@anthropic.com>
This commit is contained in:
null 2026-08-19 22:13:07 -05:00
parent 4456f351ac
commit 9a0a644fdb
22 changed files with 1582 additions and 23 deletions

View File

@ -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 0607 |
| 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

View File

@ -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"))

View File

@ -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 {

View File

@ -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)
}
}
}
}

View File

@ -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<ExportOutcome> = _outcome.asStateFlow()
/** Non-null when a picker should be opened, carrying the suggested file name. */
private val _launchRequest = MutableStateFlow<String?>(null)
val launchRequest: StateFlow<String?> = _launchRequest.asStateFlow()
/** Where the user chose to put it. Held until the session is unlocked. */
private val _destination = MutableStateFlow<Uri?>(null)
val destination: StateFlow<Uri?> = _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
}
}

View File

@ -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"
}

View File

@ -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) }
}
}
}

View File

@ -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"

View File

@ -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 = {}) }
}

View File

@ -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<ExportOutcome> = 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()
}

View File

@ -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)
}

View File

@ -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"

View File

@ -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<Context>()
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<String>(), 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<java.io.File>(), leftovers)
val inFiles = context.filesDir.walkTopDown()
.filter { it.isFile && it.name.contains("records") }
.toList()
assertEquals(emptyList<java.io.File>(), 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())
}
}

View File

@ -44,7 +44,7 @@ plugins {
val allowedProjectDependencies: Map<String, Set<String>> = 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<String, Set<String>> = 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<String, Set<String>> = 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<String, Set<String>>()
val observedAndroidPlugins = mutableMapOf<String, List<String>>()
val containerProjects = mutableSetOf<String>()
/** Directory of every real module, relative to the root, filled in `afterEvaluate`. */
val observedModuleDirs = mutableSetOf<String>()
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<ProjectDependency>() }
@ -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<String, String> = 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<String> = 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 `<uses-permission>` only, so a `<provider>` 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<String> = 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<String>()
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"))
}
}

View File

@ -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)
}

View File

@ -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<PeriodRecord>,
spotting: List<SpottingRecord>,
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()
}
}

View File

@ -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<PeriodRecord> = 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<SpottingRecord> = 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,
)
}

View File

@ -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<String>(), 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("}"))
}
}

View File

@ -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"
}
}

View File

@ -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 `<uses-permission>` and a `<provider>` merges green |
| `.githooks/` | pre-commit, commit-msg, post-commit — see [githooks/README.md](githooks/README.md) |
## What does not belong here

View File

@ -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

View File

@ -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")