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:
parent
4456f351ac
commit
9a0a644fdb
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
@ -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 = {}) }
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
153
build.gradle.kts
153
build.gradle.kts
|
|
@ -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"))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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("}"))
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Reference in New Issue