feat: let an archive come back, and refuse everything that is not one

The export was a one-way door. A user changing phone started the prediction
model from zero, which made "your history is yours" a smaller promise than it
sounded — and the archive says, in its own prose, "keep this file, a future
version of the app will be able to read it back". Nothing checked that was true.

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

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

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

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

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

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

closes #58

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
null 2026-08-21 02:27:42 -05:00
parent 7910ffd05f
commit 0ae92bd134
27 changed files with 2332 additions and 17 deletions

View File

@ -17,9 +17,12 @@ import dev.privacyllc.period.core.notifications.ReminderActionRequest
import dev.privacyllc.period.core.security.AppLockRepository
import dev.privacyllc.period.designsystem.PeriodTheme
import dev.privacyllc.period.feature.export.ExportController
import dev.privacyllc.period.feature.export.ImportController
import dev.privacyllc.period.feature.export.ImportProblem
import dev.privacyllc.period.lock.AppLockController
import dev.privacyllc.period.lock.AppLockGate
import dev.privacyllc.period.navigation.PeriodRoot
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.launch
import javax.inject.Inject
@ -43,6 +46,8 @@ class MainActivity : FragmentActivity() {
@Inject lateinit var exportController: ExportController
@Inject lateinit var importController: ImportController
/**
* The save dialog, registered as an activity field.
*
@ -62,6 +67,21 @@ class MainActivity : FragmentActivity() {
exportController.onDestination(uri)
}
/**
* The open dialog, registered as an activity field for the same reason
* [createExport] is.
*
* `OpenDocument` rather than `GetContent`: the latter can hand back a
* provider-generated copy of something that is not a file, and this needs a
* document the user can point at again. The MIME filter is a *filter*, not a
* check a picker will happily return whatever a provider claims is JSON,
* so the file is still identified by its own `format` field once it is read.
*/
private val openArchive =
registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
importController.onSource(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
@ -105,6 +125,24 @@ class MainActivity : FragmentActivity() {
}
}
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
importController.launchRequest.filter { it }.collect {
importController.onPickerLaunched()
try {
// Two types, not one: a file saved through a provider
// that did not keep the extension comes back as
// `application/octet-stream`, and refusing to show it
// would leave the user's own archive greyed out in the
// picker with no way to say "that one".
openArchive.launch(arrayOf("application/json", "application/octet-stream"))
} catch (unavailable: ActivityNotFoundException) {
importController.onFailed(ImportProblem.UNREADABLE)
}
}
}
}
setContent {
PeriodTheme {
AppLockGate {

View File

@ -0,0 +1,134 @@
package dev.privacyllc.period.feature.export
import android.content.Context
import android.net.Uri
import dev.privacyllc.period.core.data.CycleRepository
import dev.privacyllc.period.core.data.ImportMode
import dev.privacyllc.period.core.datastore.UserPreferencesRepository
import dev.privacyllc.period.core.export.ExportReader
import dev.privacyllc.period.core.export.ImportException
import dev.privacyllc.period.core.export.ImportFailure
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import javax.inject.Inject
import javax.inject.Singleton
/**
* Reads a file the user picked, and puts it back where it came from.
*
* The mirror of [DataExporter], and the same three decisions for the same
* reasons.
*
* ## It reads the whole file before it writes anything
*
* A stream that fails halfway through parsing must not have already put half a
* history in the database. Everything is parsed into memory first, and the
* database is touched once, in one transaction, with a complete archive in
* hand. A realistic archive is tens of kilobytes.
*
* ## There is a size limit, and it is here
*
* A `content://` URI can point at anything the user can reach — a video, a
* database dump, a file that is not what it looks like. Reading it whole means
* reading whatever it is into memory, so anything past [MAX_BYTES] is refused
* as unreadable without being read. The number is far above any real archive:
* a decade of daily entries is under a megabyte.
*
* ## Nothing here logs
*
* `app` is in `modulesSeeingHealthData`, and this function holds an entire
* history as a single string. Failures are reported as states, never as
* messages carrying a cause the parse failures in particular, which would
* otherwise name the line they choked on and quote it.
*/
@Singleton
class DataImporter @Inject constructor(
private val repository: CycleRepository,
private val preferences: UserPreferencesRepository,
private val controller: ImportController,
) {
/**
* Its own scope, living as long as the process.
*
* Not `viewModelScope`: the app re-locking mid-import would cancel the
* write. The handler is empty for the reason `PeriodApplication` gives
* an exception on this path carries the record it failed on.
*/
private val scope = CoroutineScope(
SupervisorJob() + Dispatchers.IO + CoroutineExceptionHandler { _, _ -> },
)
fun import(context: Context, source: Uri, mode: ImportMode) {
controller.onReading()
scope.launch {
val text = readText(context, source)
if (text == null) {
controller.onFailed(ImportProblem.UNREADABLE)
return@launch
}
val parsed = ExportReader.read(text).getOrElse { failure ->
controller.onFailed(
when ((failure as? ImportException)?.failure) {
is ImportFailure.NewerFormat -> ImportProblem.NEWER_FORMAT
ImportFailure.Damaged -> ImportProblem.DAMAGED
ImportFailure.NotOurFile -> ImportProblem.NOT_OURS
// A reader that threw something other than its own
// failure type is a reader with a bug, and the honest
// report is that the file was not read.
null -> ImportProblem.UNREADABLE
},
)
return@launch
}
val summary = runCatching {
repository.importHistory(parsed.periods, parsed.spotting, mode)
}.getOrElse {
controller.onFailed(ImportProblem.DAMAGED)
return@launch
}
// After the history, and never a reason to fail the restore. The
// records are what she came for; a reminder toggle that did not
// take is a switch she can flip. Reporting a failure here would
// tell her the restore did not happen when it did.
runCatching { parsed.settings.applyTo(preferences) }
controller.onDone(summary)
}
}
private fun readText(context: Context, source: Uri): String? = runCatching {
context.contentResolver.openInputStream(source)?.use { stream ->
val bytes = stream.readAtMost(MAX_BYTES + 1)
if (bytes.size > MAX_BYTES) null else String(bytes, Charsets.UTF_8)
}
}.getOrNull()
/**
* Read at most [limit] bytes, without asking the stream how long it is.
*
* `available()` is a hint and a `content://` provider may not implement it
* at all, so the cap has to be enforced by the read itself.
*/
private fun java.io.InputStream.readAtMost(limit: Int): ByteArray {
val out = java.io.ByteArrayOutputStream()
val buffer = ByteArray(8 * 1024)
while (out.size() <= limit) {
val read = read(buffer)
if (read < 0) break
out.write(buffer, 0, read)
}
return out.toByteArray()
}
private companion object {
/** 8 MB. A decade of daily entries is under one. */
const val MAX_BYTES = 8 * 1024 * 1024
}
}

View File

@ -1,7 +1,10 @@
package dev.privacyllc.period.feature.export
import android.content.Context
import dev.privacyllc.period.core.datastore.AppTheme
import dev.privacyllc.period.core.datastore.NotificationPrivacy
import dev.privacyllc.period.core.datastore.UserPreferences
import dev.privacyllc.period.core.datastore.UserPreferencesRepository
import dev.privacyllc.period.core.export.ExportedSettings
/**
@ -54,3 +57,36 @@ internal fun installedVersionName(context: Context): String =
runCatching {
context.packageManager.getPackageInfo(context.packageName, 0).versionName
}.getOrNull() ?: "unknown"
/**
* The settings in an archive, back onto this phone explicitly and totally,
* for the reason [toExportedSettings] gives in reverse.
*
* Two fields in the file are deliberately **not** applied:
*
* - **`biometricUnlock`** a fact about a lock this file cannot reach. The
* PIN is not in the archive and cannot be, so honouring the flag would at
* best do nothing and at worst turn on a fingerprint shortcut for a lock the
* user set on this phone, from a file that could have come from anywhere.
* The lock is left exactly as she set it, and the restore screen says so.
* - **`theme` and `notificationPrivacy` when the file names something this
* build does not have** an unknown enum constant is dropped rather than
* guessed at. A newer version's theme is not this version's problem.
*
* Failures are per field on purpose: a preference that will not write must not
* take the history that has already been restored down with it.
*/
internal suspend fun ExportedSettings.applyTo(preferences: UserPreferencesRepository) {
NotificationPrivacy.entries.firstOrNull { it.name == notificationPrivacy }
?.let { preferences.setNotificationPrivacy(it) }
AppTheme.entries.firstOrNull { it.name == theme }
?.let { preferences.setTheme(it) }
preferences.setReminderTime(reminderTime)
preferences.setPeriodApproachingEnabled(periodApproaching)
preferences.setPeriodExpectedTodayEnabled(periodExpectedToday)
preferences.setDidItStartEnabled(didItStart)
preferences.setPeriodEndCheckInEnabled(periodEndCheckIn)
preferences.setFertileWindowReminderEnabled(fertileWindow)
preferences.setOvulationReminderEnabled(ovulation)
}

View File

@ -0,0 +1,135 @@
package dev.privacyllc.period.feature.export
import android.net.Uri
import dev.privacyllc.period.core.data.ImportMode
import dev.privacyllc.period.core.data.ImportSummary
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import javax.inject.Inject
import javax.inject.Singleton
/** Why a file could not be restored, in the four shapes a user can act on. */
enum class ImportProblem {
/** Not a file this app wrote. */
NOT_OURS,
/** This app's file, from a version that writes something this build cannot read. */
NEWER_FORMAT,
/** This app's file, damaged. */
DAMAGED,
/** The file itself could not be read — permission withdrawn, storage gone, no stream. */
UNREADABLE,
}
/** Where a restore has got to. One shot, cleared when the user acknowledges it. */
sealed interface ImportState {
data object Idle : ImportState
/** A file was chosen while the app was locked. Nothing has been read yet. */
data object PendingUnlock : ImportState
data object Reading : ImportState
data class Done(val summary: ImportSummary) : ImportState
data class Failed(val problem: ImportProblem) : ImportState
}
/** A file the user picked, and what she chose to do with it. */
data class PendingImport(val source: Uri, val mode: ImportMode)
/**
* The restore's state, held for as long as the process rather than a screen.
*
* Same shape and same reason as [ExportController]: picking a file leaves the
* app, and coming back can re-lock it, which clears every nav-scoped ViewModel.
* State that lived in one would be gone exactly when the result arrives.
*
* ## Why the choice is made before the picker, not after
*
* Merge or replace is the user's decision and the app must not guess it. It
* would be friendlier to ask *after* reading the file, when the app could say
* how much is in it but the screen that asked would be the screen a re-lock
* has just destroyed, and rebuilding a decision dialog above the tabs to
* survive that is a lot of machinery for a question that has a good answer
* earlier. So the mode travels with the request, and the screen that asks it
* spells out what each one does before the picker opens.
*
* Nothing here is persisted. A file chosen before the app was killed is not a
* file somebody still wants imported, and reviving it silently later would be a
* write to the health record that nobody asked for.
*/
@Singleton
class ImportController @Inject constructor() {
private val _state = MutableStateFlow<ImportState>(ImportState.Idle)
val state: StateFlow<ImportState> = _state.asStateFlow()
/** True when a picker should be opened. */
private val _launchRequest = MutableStateFlow(false)
val launchRequest: StateFlow<Boolean> = _launchRequest.asStateFlow()
private val _pending = MutableStateFlow<PendingImport?>(null)
val pending: StateFlow<PendingImport?> = _pending.asStateFlow()
/** Held from the moment she chooses until the picker comes back. */
private var chosenMode: ImportMode? = null
fun requestPicker(mode: ImportMode) {
_state.value = ImportState.Idle
chosenMode = mode
_launchRequest.value = true
}
/** Consumed by the activity once the picker is open. */
fun onPickerLaunched() {
_launchRequest.value = false
}
/**
* The picker came back.
*
* A null uri is a cancellation, and cancelling says nothing at all. The
* mode is dropped with it: a choice made for a file she did not pick is not
* a choice about the next one.
*/
fun onSource(uri: Uri?) {
val mode = chosenMode
chosenMode = null
if (uri == null || mode == null) {
_state.value = ImportState.Idle
return
}
_pending.value = PendingImport(uri, mode)
_state.value = ImportState.PendingUnlock
}
/**
* Take the file, once, to read it.
*
* Cleared as it is handed over so a relock/unlock cycle cannot apply the
* same archive twice. A second merge would be harmless every date is
* already recorded but a second *replace* would wipe whatever she had
* entered in between.
*/
fun takePending(): PendingImport? = _pending.value.also { _pending.value = null }
fun onReading() {
_state.value = ImportState.Reading
}
fun onDone(summary: ImportSummary) {
_state.value = ImportState.Done(summary)
}
fun onFailed(problem: ImportProblem) {
_state.value = ImportState.Failed(problem)
}
fun acknowledge() {
_state.value = ImportState.Idle
}
}

View File

@ -0,0 +1,103 @@
package dev.privacyllc.period.feature.export
import dev.privacyllc.period.core.data.ImportSummary
/**
* Everything the restore screen says, in one place so it can be tested.
*
* The screen has one job the export screen does not: it asks for a decision
* that cannot be taken back. So the two choices are spelled out in terms of
* what happens to what is already on the phone not "merge" and "replace",
* which are words about the app, but "add to" and "delete first", which are
* words about her records.
*/
internal object ImportCopy {
const val ROW_TITLE = "Restore from a file"
const val ROW_SUBTITLE = "Bring your records back from a file you exported"
const val TITLE = "Restore from a file"
const val WHAT_HEADING = "What comes back"
const val WHAT_BODY =
"Every period and spotting entry in the file, and the settings saved with it — " +
"your reminders, how notifications are shown, and the theme.\n\n" +
"Your app PIN does not: it was never in the file, and it belongs to the " +
"phone you set it on. The lock on this phone stays exactly as it is."
const val WHICH_HEADING = "Only files this app wrote"
const val WHICH_BODY =
"A file from this app, saved by Export my data. Nothing else will be read — " +
"not a spreadsheet, not another app's export, not a file that has been " +
"edited into something else."
const val CHOOSE_HEADING = "What happens to what is here"
const val CHOOSE_BODY =
"You choose, before the file is opened. Whichever you pick, the forecast is " +
"worked out again once everything is in."
const val ADD_ACTION = "Add to what is here"
const val ADD_DETAIL =
"Keeps everything on this phone and adds anything in the file that is missing. " +
"A day recorded in both stays as you recorded it here."
const val REPLACE_ACTION = "Replace what is here"
const val REPLACE_DETAIL =
"Deletes every period and spotting entry on this phone first, then takes the " +
"file as the record."
const val REPLACE_CONFIRM_TITLE = "Delete what is on this phone?"
const val REPLACE_CONFIRM_BODY =
"Everything you have recorded on this phone will be deleted and replaced by " +
"what is in the file you pick next. There is no way back from this, and no " +
"copy is kept.\n\n" +
"If you are not sure the file is the right one, choose Add instead — it " +
"cannot lose anything."
const val REPLACE_CONFIRM_ACTION = "Delete and replace"
const val CANCEL = "Cancel"
const val WORKING = "Restoring…"
const val ACKNOWLEDGE = "Done"
const val NOT_OURS =
"That is not a file this app wrote, so nothing was read from it. Your records " +
"here are untouched."
const val NEWER_FORMAT =
"That file was saved by a newer version of this app than the one on this phone. " +
"Update the app and try again — nothing was read, and your records here are " +
"untouched."
const val DAMAGED =
"That file is from this app but could not be read all the way through, so " +
"nothing was taken from it. Your records here are untouched."
const val UNREADABLE =
"That file could not be opened. Nothing was read, and your records here are " +
"untouched."
/**
* What arrived, in counts.
*
* Never dates, and never a range: this string is read aloud by a screen
* reader and sits in a banner above whatever screen the user landed on,
* which may be in a room with other people in it.
*/
fun done(summary: ImportSummary): String {
if (!summary.addedAnything) {
return "Everything in that file was already recorded here, so nothing changed."
}
val parts = buildList {
if (summary.periodsAdded > 0) add(count(summary.periodsAdded, "period", "periods"))
if (summary.spottingAdded > 0) {
add(count(summary.spottingAdded, "spotting day", "spotting days"))
}
}
val already = summary.periodsAlreadyRecorded + summary.spottingAlreadyRecorded
val tail = when (already) {
0 -> ""
1 -> " 1 entry was already here."
else -> " $already entries were already here."
}
return "Restored. ${parts.joinToString(" and ")} added.$tail"
}
private fun count(n: Int, one: String, many: String) = "$n ${if (n == 1) one else many}"
}

View File

@ -0,0 +1,81 @@
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.foundation.layout.statusBarsPadding
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
/**
* Reads a parked archive, and reports what happened wherever the user is.
*
* The mirror of [ExportHost], for the same reason: choosing a file leaves the
* app, coming back can re-lock it, and the user then unlocks onto Today rather
* than the screen she started from. A result rendered by the restore screen
* would frequently be rendered to nobody and "nothing happened" and "your
* history has just been replaced" must never look the same.
*
* The read happens here because here is after the unlock.
*/
@Composable
fun ImportHost(viewModel: ImportViewModel = hiltViewModel()) {
val context = LocalContext.current
val pending by viewModel.pending.collectAsStateWithLifecycle()
val state by viewModel.state.collectAsStateWithLifecycle()
LaunchedEffect(pending) {
if (pending != null) viewModel.readPendingImport(context)
}
val current = state
val message = when (current) {
is ImportState.Done -> ImportCopy.done(current.summary)
is ImportState.Failed -> when (current.problem) {
ImportProblem.NOT_OURS -> ImportCopy.NOT_OURS
ImportProblem.NEWER_FORMAT -> ImportCopy.NEWER_FORMAT
ImportProblem.DAMAGED -> ImportCopy.DAMAGED
ImportProblem.UNREADABLE -> ImportCopy.UNREADABLE
}
// A cancelled picker says nothing at all. "Restoring…" is shown by the
// screen she is standing on, which still exists while it runs.
else -> null
} ?: return
Surface(color = MaterialTheme.colorScheme.surfaceVariant) {
Column(
Modifier
.fillMaxWidth()
.statusBarsPadding()
.padding(horizontal = 24.dp, vertical = 12.dp),
) {
Text(
message,
style = MaterialTheme.typography.bodyMedium,
color = if (current is ImportState.Done) {
MaterialTheme.colorScheme.onSurfaceVariant
} else {
MaterialTheme.colorScheme.error
},
// The result arrives after returning from another app's UI with
// no focus change; without a live region a screen-reader user is
// never told whether her history came back.
modifier = Modifier.semantics { liveRegion = LiveRegionMode.Polite },
)
TextButton(onClick = viewModel::acknowledge) { Text(ImportCopy.ACKNOWLEDGE) }
}
}
}

View File

@ -0,0 +1,156 @@
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.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
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.core.data.ImportMode
import dev.privacyllc.period.designsystem.PeriodTheme
import dev.privacyllc.period.feature.common.SettingsSubpage
/**
* The disclosure, and the one decision the app must not take for her.
*
* The result is not rendered here, for the reason [ImportHost] gives: the
* picker leaves the app and coming back can re-lock it, so this screen may not
* exist by the time the archive has been read.
*/
@Composable
fun ImportScreen(onNavigateBack: () -> Unit, viewModel: ImportViewModel = hiltViewModel()) {
val state by viewModel.state.collectAsStateWithLifecycle()
ImportScreenContent(
working = state == ImportState.Reading,
onImport = viewModel::begin,
onNavigateBack = onNavigateBack,
)
}
@Composable
internal fun ImportScreenContent(
working: Boolean,
onImport: (ImportMode) -> Unit,
onNavigateBack: () -> Unit = {},
) {
// Not remembered across configuration change on purpose: a rotation while a
// "delete everything" dialog is open should not leave it standing over a
// screen the user has stopped looking at.
var confirmingReplace by remember { mutableStateOf(false) }
SettingsSubpage(title = ImportCopy.TITLE, onBack = onNavigateBack) {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(24.dp),
) {
Section(ImportCopy.WHAT_HEADING, ImportCopy.WHAT_BODY)
Section(ImportCopy.WHICH_HEADING, ImportCopy.WHICH_BODY)
Section(ImportCopy.CHOOSE_HEADING, ImportCopy.CHOOSE_BODY)
Spacer(Modifier.height(24.dp))
// Add first, and as the filled button: it is the choice that cannot
// lose anything, and the one somebody restoring a new phone wants.
Button(
onClick = { onImport(ImportMode.MERGE) },
enabled = !working,
modifier = Modifier.fillMaxWidth(),
) {
Text(if (working) ImportCopy.WORKING else ImportCopy.ADD_ACTION)
}
Detail(ImportCopy.ADD_DETAIL)
Spacer(Modifier.height(20.dp))
OutlinedButton(
onClick = { confirmingReplace = true },
enabled = !working,
modifier = Modifier.fillMaxWidth(),
) {
Text(ImportCopy.REPLACE_ACTION)
}
Detail(ImportCopy.REPLACE_DETAIL)
}
}
if (confirmingReplace) {
AlertDialog(
onDismissRequest = { confirmingReplace = false },
title = { Text(ImportCopy.REPLACE_CONFIRM_TITLE) },
text = { Text(ImportCopy.REPLACE_CONFIRM_BODY) },
confirmButton = {
TextButton(
onClick = {
confirmingReplace = false
onImport(ImportMode.REPLACE)
},
) {
// Named for what it does. "OK" on an irreversible delete is
// how people confirm things they did not read.
Text(
ImportCopy.REPLACE_CONFIRM_ACTION,
color = MaterialTheme.colorScheme.error,
)
}
},
dismissButton = {
TextButton(onClick = { confirmingReplace = false }) { Text(ImportCopy.CANCEL) }
},
)
}
}
@Composable
private fun Section(heading: String, body: String) {
Spacer(Modifier.height(24.dp))
Text(
heading,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.semantics { heading() },
)
Spacer(Modifier.height(6.dp))
Text(body, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
@Composable
private fun Detail(body: String) {
Spacer(Modifier.height(8.dp))
Text(body, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
@Preview(name = "Restore · light", showBackground = true, heightDp = 1000)
@Preview(
name = "Restore · dark",
showBackground = true,
heightDp = 1000,
uiMode = Configuration.UI_MODE_NIGHT_YES,
)
@Preview(name = "Restore · font 2.0", showBackground = true, heightDp = 2200, fontScale = 2.0f)
@Composable
private fun ImportPreview() {
PeriodTheme { ImportScreenContent(working = false, onImport = {}) }
}

View File

@ -0,0 +1,41 @@
package dev.privacyllc.period.feature.export
import android.content.Context
import androidx.lifecycle.ViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import dev.privacyllc.period.core.data.ImportMode
import kotlinx.coroutines.flow.StateFlow
import javax.inject.Inject
/**
* Thin, for the reason [ExportViewModel] is thin: everything that has to
* survive leaving the app for a file picker lives in [ImportController].
*/
@HiltViewModel
class ImportViewModel @Inject constructor(
private val controller: ImportController,
private val importer: DataImporter,
) : ViewModel() {
val state: StateFlow<ImportState> = controller.state
/** Non-null while a chosen file is waiting to be read. */
val pending = controller.pending
fun begin(mode: ImportMode) = controller.requestPicker(mode)
/**
* Read the file the user picked, now that the session is unlocked.
*
* Called only from inside the gate's unlocked branch the same rule the
* export write follows. Restoring while locked would let somebody who took
* the phone during the picker overwrite the owner's history without ever
* passing the lock.
*/
fun readPendingImport(context: Context) {
val pending = controller.takePending() ?: return
importer.import(context.applicationContext, pending.source, pending.mode)
}
fun acknowledge() = controller.acknowledge()
}

View File

@ -33,6 +33,7 @@ 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.ImportCopy
import dev.privacyllc.period.feature.export.installedVersionName
import dev.privacyllc.period.designsystem.PeriodTheme
@ -65,6 +66,7 @@ fun SettingsScreen(
onOpenNotifications: () -> Unit,
onOpenAppLock: () -> Unit,
onOpenExport: () -> Unit,
onOpenImport: () -> Unit,
viewModel: PrivacyViewModel? = hiltViewModel(),
) {
val deletion = viewModel?.deletion?.collectAsStateWithLifecycle()?.value ?: DeletionState.IDLE
@ -109,6 +111,14 @@ fun SettingsScreen(
subtitle = ExportCopy.ROW_SUBTITLE,
onClick = onOpenExport,
)
// Directly under Export, and above Delete. A copy that cannot come back
// is not a copy, and somebody about to erase everything should be able
// to see, one row up, that there is a way back from a file.
SettingsRow(
title = ImportCopy.ROW_TITLE,
subtitle = ImportCopy.ROW_SUBTITLE,
onClick = onOpenImport,
)
SettingsRow(
title = "Delete my data",
subtitle = "Erase every period, spotting and prediction record",
@ -422,5 +432,11 @@ private fun StaticRow(title: String, value: String) {
)
@Composable
private fun PreviewSettingsRoot() = PeriodTheme {
SettingsScreen(onOpenNotifications = {}, onOpenAppLock = {}, onOpenExport = {}, viewModel = null)
SettingsScreen(
onOpenNotifications = {},
onOpenAppLock = {},
onOpenExport = {},
onOpenImport = {},
viewModel = null,
)
}

View File

@ -41,6 +41,8 @@ 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.export.ImportHost
import dev.privacyllc.period.feature.export.ImportScreen
import dev.privacyllc.period.feature.insights.InsightsScreen
import dev.privacyllc.period.feature.lock.LockSettingsScreen
import dev.privacyllc.period.feature.onboarding.OnboardingScreen
@ -104,7 +106,15 @@ fun PeriodApp() {
// earlier ones, so an opaque Scaffold was painted over it. It has
// almost certainly never been seen; the export's on-device checks were
// never run either.
topBar = { ExportHost() },
topBar = {
// Both results, one above the other. Only one can be non-empty at a
// time in practice — the two flows both go through the picker and
// the lock — and each renders nothing at all when it is idle.
Column {
ExportHost()
ImportHost()
}
},
bottomBar = {
NavigationBar {
PeriodDestination.entries.forEach { destination ->
@ -181,6 +191,7 @@ fun PeriodApp() {
onOpenNotifications = { navController.navigate(SETTINGS_NOTIFICATIONS) },
onOpenAppLock = { navController.navigate(SETTINGS_LOCK) },
onOpenExport = { navController.navigate(SETTINGS_EXPORT) },
onOpenImport = { navController.navigate(SETTINGS_IMPORT) },
)
}
composable(SETTINGS_NOTIFICATIONS) {
@ -192,6 +203,9 @@ fun PeriodApp() {
composable(SETTINGS_EXPORT) {
ExportScreen(onNavigateBack = { navController.popBackStack() })
}
composable(SETTINGS_IMPORT) {
ImportScreen(onNavigateBack = { navController.popBackStack() })
}
}
}
}
@ -257,6 +271,9 @@ 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"
/** Restore, directly under Export: the copy is only a copy if it can come back. */
private const val SETTINGS_IMPORT = "settings/import"
/** True while [tab] is anywhere in this destination's hierarchy, children included. */
private fun NavDestination?.isIn(tab: PeriodDestination) =
this?.hierarchy?.any { it.route == tab.route } == true

View File

@ -0,0 +1,252 @@
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.data.ImportMode
import dev.privacyllc.period.core.datastore.AppTheme
import dev.privacyllc.period.core.datastore.NotificationPrivacy
import dev.privacyllc.period.core.datastore.UserPreferencesRepository
import dev.privacyllc.period.core.export.ExportDocument
import dev.privacyllc.period.core.export.ExportedSettings
import dev.privacyllc.period.domain.cycle.PeriodRecord
import dev.privacyllc.period.domain.cycle.PeriodRecordSource
import dev.privacyllc.period.domain.cycle.SpottingRecord
import dev.privacyllc.period.domain.prediction.PersonalPredictionEngine
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import org.junit.Assert.assertEquals
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.ByteArrayInputStream
import java.time.Clock
import java.time.LocalDate
import java.time.LocalTime
import java.time.ZoneOffset
/**
* Reading an archive back off the phone's storage.
*
* The round trip itself is pinned in `:core:export`. What is checked here is
* the part that only exists on Android: that the file comes from a document the
* *user* pointed at, that a file which is not ours changes nothing, and that
* the one setting an archive must never be allowed to reach the app lock
* does not move.
*/
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class DataImporterTest {
private val context = ApplicationProvider.getApplicationContext<Context>()
private val today = LocalDate.of(2026, 8, 20)
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: ImportController
private lateinit var importer: DataImporter
private val source: Uri = Uri.parse("content://test.documents/document/7")
@Before fun setUp() {
repo = CycleData.repository(context, PersonalPredictionEngine(), clock)
runBlocking {
repo.deleteAllHealthData()
// The database is a real file shared by every test in this class,
// and a delete that has not reached the observer yet makes the next
// test read the previous one's history.
withTimeout(10_000) { while (repo.confirmedPeriods.first().isNotEmpty()) delay(10) }
}
prefs = UserPreferencesRepository(
PreferenceDataStoreFactory.create {
context.preferencesDataStoreFile("import_test_${System.nanoTime()}")
},
)
controller = ImportController()
importer = DataImporter(repo, prefs, controller)
}
private fun await(predicate: () -> Boolean) = runBlocking {
withTimeout(20_000) { while (!predicate()) delay(20) }
}
private fun offer(text: String) {
Shadows.shadowOf(context.contentResolver)
.registerInputStream(source, ByteArrayInputStream(text.toByteArray(Charsets.UTF_8)))
}
private fun archive(
periods: List<PeriodRecord> = listOf(
PeriodRecord(0, LocalDate.of(2026, 6, 19), LocalDate.of(2026, 6, 24)),
PeriodRecord(0, LocalDate.of(2026, 7, 16)),
),
settings: ExportedSettings = ExportedSettings(
notificationPrivacy = "DIRECT",
reminderTime = LocalTime.of(7, 30),
periodApproaching = false,
periodExpectedToday = false,
didItStart = false,
periodEndCheckIn = false,
fertileWindow = true,
ovulation = true,
// The one an archive must never be allowed to apply.
biometricUnlock = true,
theme = "DARK",
),
) = ExportDocument.render(
periods = periods,
spotting = listOf(SpottingRecord(0, LocalDate.of(2026, 7, 13))),
settings = settings,
appVersion = "0.1.0",
exportedOn = LocalDate.of(2026, 8, 19),
)
// -----------------------------------------------------------------------
// The document the user pointed at
// -----------------------------------------------------------------------
/**
* Pinned as a property of the *intent*, so nobody can swap in `GetContent`,
* which may hand back a provider-generated copy of something that is not a
* document at all.
*/
@Test fun `the picker asks for a document the user opens`() {
val intent = ActivityResultContracts.OpenDocument()
.createIntent(context, arrayOf("application/json"))
assertEquals(android.content.Intent.ACTION_OPEN_DOCUMENT, intent.action)
assertEquals(
listOf("application/json"),
intent.getStringArrayExtra(android.content.Intent.EXTRA_MIME_TYPES)?.toList(),
)
}
// -----------------------------------------------------------------------
// What arrives
// -----------------------------------------------------------------------
@Test fun `an archive on the phone's storage comes back into the record`() {
offer(archive())
importer.import(context, source, ImportMode.MERGE)
await { controller.state.value is ImportState.Done }
val periods = runBlocking { repo.confirmedPeriods.first() }
assertEquals(
listOf(LocalDate.of(2026, 6, 19), LocalDate.of(2026, 7, 16)),
periods.map { it.startDate },
)
assertTrue(periods.all { it.source == PeriodRecordSource.IMPORTED })
assertEquals(1, runBlocking { repo.spotting.first() }.size)
}
@Test fun `the settings saved with it come back too`() {
offer(archive())
importer.import(context, source, ImportMode.MERGE)
await { controller.state.value is ImportState.Done }
val settings = runBlocking { prefs.preferences.first() }
assertEquals(NotificationPrivacy.DIRECT, settings.notificationPrivacy)
assertEquals(LocalTime.of(7, 30), settings.reminderTime)
assertEquals(AppTheme.DARK, settings.theme)
assertTrue(settings.fertileWindowReminderEnabled)
assertTrue("a reminder switched off in the file came back on", !settings.didItStartEnabled)
}
/**
* The one setting a file must never be able to move.
*
* A PIN is not in an archive and cannot be, so honouring `biometricUnlock`
* would at best do nothing and at worst turn on a fingerprint shortcut for
* a lock set on this phone, from a file that could have come from anywhere.
*/
@Test fun `an archive cannot change the app lock`() {
offer(archive())
importer.import(context, source, ImportMode.MERGE)
await { controller.state.value is ImportState.Done }
assertTrue(
"an imported file switched on a lock setting",
!runBlocking { prefs.preferences.first() }.biometricLockEnabled,
)
}
// -----------------------------------------------------------------------
// What it refuses, and what it leaves alone when it does
// -----------------------------------------------------------------------
@Test fun `somebody else's file changes nothing`() {
runBlocking { repo.confirmPeriodStart(LocalDate.of(2026, 8, 1)) }
offer("""{"cycles":[{"start":"2026-01-01"}]}""")
importer.import(context, source, ImportMode.REPLACE)
await { controller.state.value is ImportState.Failed }
assertEquals(ImportProblem.NOT_OURS, (controller.state.value as ImportState.Failed).problem)
// REPLACE was chosen, and the wipe must not have happened: the file was
// refused before the database was touched.
assertEquals(1, runBlocking { repo.confirmedPeriods.first() }.size)
}
@Test fun `a damaged file of ours changes nothing`() {
runBlocking { repo.confirmPeriodStart(LocalDate.of(2026, 8, 1)) }
val whole = archive()
offer(whole.substring(0, whole.length / 2))
importer.import(context, source, ImportMode.REPLACE)
await { controller.state.value is ImportState.Failed }
assertEquals(ImportProblem.DAMAGED, (controller.state.value as ImportState.Failed).problem)
assertEquals(1, runBlocking { repo.confirmedPeriods.first() }.size)
}
@Test fun `a file that cannot be opened is reported, not crashed on`() {
// Nothing registered for this uri: the resolver returns no stream, the
// shape a revoked grant or a removed SD card produces.
importer.import(context, Uri.parse("content://test.documents/document/gone"), ImportMode.MERGE)
await { controller.state.value is ImportState.Failed }
assertEquals(ImportProblem.UNREADABLE, (controller.state.value as ImportState.Failed).problem)
}
@Test fun `a file far too large to be an archive is not read into memory`() {
// 9 MB of valid-looking opening. A decade of daily entries is under one.
offer("{\"format\":\"${ExportDocument.FORMAT}\"," + " ".repeat(9 * 1024 * 1024))
importer.import(context, source, ImportMode.MERGE)
await { controller.state.value is ImportState.Failed }
assertEquals(ImportProblem.UNREADABLE, (controller.state.value as ImportState.Failed).problem)
}
// -----------------------------------------------------------------------
// Replace
// -----------------------------------------------------------------------
@Test fun `replace takes the file as the record`() {
runBlocking { repo.confirmPeriodStart(LocalDate.of(2026, 8, 1)) }
offer(archive())
importer.import(context, source, ImportMode.REPLACE)
await { controller.state.value is ImportState.Done }
assertEquals(
listOf(LocalDate.of(2026, 6, 19), LocalDate.of(2026, 7, 16)),
runBlocking { repo.confirmedPeriods.first() }.map { it.startDate },
)
}
}

View File

@ -0,0 +1,79 @@
package dev.privacyllc.period.feature.export
import android.net.Uri
import dev.privacyllc.period.core.data.ImportMode
import dev.privacyllc.period.core.data.ImportSummary
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
/**
* The state that has to survive leaving the app for a file picker.
*
* Two of these are about a *second* application of the same archive. A repeated
* merge would be harmless every date in it is already recorded but a
* repeated replace wipes whatever the user entered in between, which is the one
* outcome this feature must not have.
*/
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class ImportControllerTest {
private val controller = ImportController()
private val file: Uri = Uri.parse("content://test/1")
@Test fun `a chosen file carries the mode she chose with it`() {
controller.requestPicker(ImportMode.REPLACE)
assertTrue(controller.launchRequest.value)
controller.onPickerLaunched()
assertFalse(controller.launchRequest.value)
controller.onSource(file)
assertEquals(PendingImport(file, ImportMode.REPLACE), controller.pending.value)
assertEquals(ImportState.PendingUnlock, controller.state.value)
}
@Test fun `a cancelled picker says nothing and keeps no choice`() {
controller.requestPicker(ImportMode.REPLACE)
controller.onSource(null)
assertEquals(ImportState.Idle, controller.state.value)
assertNull(controller.pending.value)
// And the abandoned "replace" must not attach itself to the next file
// that arrives from somewhere else.
controller.onSource(file)
assertNull("a cancelled choice was applied to a later file", controller.pending.value)
}
@Test fun `a file is handed over once, so it cannot be applied twice`() {
controller.requestPicker(ImportMode.REPLACE)
controller.onSource(file)
assertEquals(PendingImport(file, ImportMode.REPLACE), controller.takePending())
assertNull("a relock and unlock would have replaced her history again", controller.takePending())
}
@Test fun `a result is one shot and clears when she acknowledges it`() {
controller.onDone(ImportSummary(3, 0, 1, 0))
assertEquals(ImportState.Done(ImportSummary(3, 0, 1, 0)), controller.state.value)
controller.acknowledge()
assertEquals(ImportState.Idle, controller.state.value)
}
@Test fun `nothing is remembered from before the process started`() {
// Deliberately not persisted: a file chosen before the app was killed
// is not a file somebody still wants written over her history.
val fresh = ImportController()
assertEquals(ImportState.Idle, fresh.state.value)
assertNull(fresh.pending.value)
assertFalse(fresh.launchRequest.value)
}
}

View File

@ -0,0 +1,84 @@
package dev.privacyllc.period.feature.export
import dev.privacyllc.period.core.data.ImportSummary
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* The screen's promises, held to the code that keeps them.
*
* Two of these are the same class of test as `LockSettingsCopyTest`: a sentence
* that tells the user what will happen is a claim, and a claim nothing checks
* is a claim that drifts away from the behaviour it describes.
*/
class ImportCopyTest {
@Test fun `it says the app lock does not come back with the file`() {
// The PIN is not in the archive and cannot be — DataImporterTest holds
// the behaviour. This holds the sentence that tells her so, because a
// user restoring a phone reasonably assumes her lock came too.
val body = ImportCopy.WHAT_BODY.lowercase()
assertTrue("the copy does not mention the PIN at all", body.contains("pin"))
assertTrue(body.contains("was never in the file"))
}
@Test fun `the destructive choice names the deletion, in both places`() {
assertTrue(ImportCopy.REPLACE_DETAIL.lowercase().contains("deletes"))
assertTrue(ImportCopy.REPLACE_CONFIRM_BODY.lowercase().contains("deleted"))
// "No way back" is the fact that matters, and it is the fact people
// skip. It belongs in the dialog, not only in the small print.
assertTrue(ImportCopy.REPLACE_CONFIRM_BODY.lowercase().contains("no way back"))
// The button says what it does. "OK" is how people confirm things they
// did not read.
assertTrue(ImportCopy.REPLACE_CONFIRM_ACTION.lowercase().contains("delete"))
}
@Test fun `the safe choice is described as safe, and the dialog points at it`() {
assertTrue(ImportCopy.ADD_DETAIL.lowercase().contains("keeps everything"))
assertTrue(
"the dialog offers no way out other than cancelling",
ImportCopy.REPLACE_CONFIRM_BODY.contains("Add"),
)
}
// -----------------------------------------------------------------------
// The result, which is read aloud in whatever room she is standing in
// -----------------------------------------------------------------------
@Test fun `the result counts, and never dates`() {
val message = ImportCopy.done(ImportSummary(12, 0, 4, 0))
assertEquals("Restored. 12 periods and 4 spotting days added.", message)
assertTrue("a date reached a message read out loud", !message.contains("-"))
}
@Test fun `one of something is not one of somethings`() {
assertEquals("Restored. 1 period added.", ImportCopy.done(ImportSummary(1, 0, 0, 0)))
assertEquals(
"Restored. 1 spotting day added. 1 entry was already here.",
ImportCopy.done(ImportSummary(0, 1, 1, 0)),
)
assertEquals(
"Restored. 2 periods added. 3 entries were already here.",
ImportCopy.done(ImportSummary(2, 2, 0, 1)),
)
}
@Test fun `an archive that adds nothing says so rather than claiming a restore`() {
// "Restored. 0 periods added." reads as a success and is one of the
// ways a user concludes the app has eaten her history.
assertEquals(
"Everything in that file was already recorded here, so nothing changed.",
ImportCopy.done(ImportSummary(0, 14, 0, 6)),
)
}
@Test fun `every failure tells her the records here are untouched`() {
listOf(ImportCopy.NOT_OURS, ImportCopy.NEWER_FORMAT, ImportCopy.DAMAGED, ImportCopy.UNREADABLE)
.forEach {
// The question a failed restore raises is not "why" — it is
// "what happened to what I had".
assertTrue("a failure message left her guessing: $it", it.contains("untouched"))
}
}
}

View File

@ -0,0 +1,75 @@
package dev.privacyllc.period.feature.export
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import dev.privacyllc.period.core.data.ImportMode
import dev.privacyllc.period.designsystem.PeriodTheme
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
/**
* The screen exists to take one decision, and one of its two answers deletes
* everything the user has recorded.
*
* So the test is not that the buttons are there. It is that the destructive
* one cannot be reached in a single tap, and that the safe one is not slowed
* down by the ceremony the destructive one needs.
*/
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class ImportScreenTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
private val chosen = mutableListOf<ImportMode>()
/**
* `performScrollTo` before every tap on a screen button.
*
* The default test window is 320×470 and this screen is three sections of
* disclosure above its two buttons, so both sit below the fold. A Compose
* click on an off-screen node lands nowhere and reports nothing the first
* version of this test read as "the dialog never opened".
*/
private fun show() = rule.setContent {
PeriodTheme { ImportScreenContent(working = false, onImport = { chosen += it }) }
}
@Test fun `adding to what is here needs no confirmation`() {
show()
rule.onNodeWithText(ImportCopy.ADD_ACTION).performScrollTo().performClick()
// Nothing can be lost, so nothing is asked. A confirmation on a safe
// action teaches people to dismiss confirmations.
assertEquals(listOf(ImportMode.MERGE), chosen)
}
@Test fun `replacing asks first, and names what it deletes`() {
show()
rule.onNodeWithText(ImportCopy.REPLACE_ACTION).performScrollTo().performClick()
assertEquals("the picker opened before she confirmed", emptyList<ImportMode>(), chosen)
rule.onNodeWithText(ImportCopy.REPLACE_CONFIRM_TITLE).assertIsDisplayed()
rule.onNodeWithText(ImportCopy.REPLACE_CONFIRM_ACTION).performClick()
assertEquals(listOf(ImportMode.REPLACE), chosen)
}
@Test fun `backing out of the confirmation replaces nothing`() {
show()
rule.onNodeWithText(ImportCopy.REPLACE_ACTION).performScrollTo().performClick()
rule.onNodeWithText(ImportCopy.CANCEL).performClick()
assertEquals(emptyList<ImportMode>(), chosen)
// And the screen is still usable: cancelling a dialog is not an exit.
rule.onNodeWithText(ImportCopy.ADD_ACTION).performScrollTo().assertIsDisplayed()
}
}

View File

@ -129,13 +129,19 @@ class LockSettingsViewModelTest {
* the whole module runs beside it a flaky guard is one people learn to
* ignore. The budget is for catching a hang, not for measuring the crypto.
*
* 60 seconds was still not enough: `:app`, `:core:data`, `:core:export` and
* `:core:datastore` in one invocation put this test over it on a machine
* where it passes in twelve seconds alone. Three minutes is absurd for what
* it measures and exactly right for what it is a stuck coroutine, not a
* slow one.
*
* The message matters as much as the number: a bare "timed out" says
* nothing about whether the write never happened, the callback never fired,
* or the state simply had not arrived yet.
*/
private fun await(what: String = "a condition", predicate: suspend () -> Boolean) = runBlocking {
try {
withTimeout(60_000) { while (!predicate()) delay(10) }
withTimeout(180_000) { while (!predicate()) delay(10) }
} catch (timeout: TimeoutCancellationException) {
throw AssertionError(
"gave up waiting for $what — busy=${vm.state.value.busy}, " +

View File

@ -263,6 +263,114 @@ class CycleRepository internal constructor(
*/
suspend fun deleteAllHealthData() = db.deleteEverything()
// -----------------------------------------------------------------------
// Reading an archive back
// -----------------------------------------------------------------------
/**
* Take a history back from a file the user exported.
*
* ## Why this is not a loop over [confirmPeriodStart]
*
* Because [confirmPeriodStart] does four things, and three of them are
* wrong here:
*
* - It **scores the standing forecast** against the start. Every record in
* an archive is history the app never showed a forecast about it, and
* scoring one would fill §16's "your predictions are getting better" with
* figures the app invented about itself. The backfill guard in
* [scoreOutstanding] catches most of this by date, but not all of it: a
* file exported this morning carries a period starting today, and that
* one would sail through. Scoring is therefore not skipped by accident of
* date here it is not reached at all.
* - It **clears the "not yet" observations** a start resolves. Those
* answer a question this phone asked; an imported record is not an answer
* to it.
* - It **snapshots a forecast**, once per record. A thousand-record archive
* would run the engine a thousand times, and the intermediate forecasts
* would be forecasts made from half a history that never existed.
*
* What it keeps is the part that matters: this is still the repository, in
* one transaction, through the same insert and the same UNIQUE start-date
* constraint. Nothing above this module reaches a DAO, and a partly-applied
* import cannot survive a failure.
*
* ## The forecast is recalculated exactly once, at the end
*
* [snapshotForecast] retires the standing snapshot and asks the engine once,
* with the whole history in place. It inherits the standing lineage origin,
* which is correct: the question *when does the next period begin?* has
* not changed, only what is known about it. An import that supersedes the
* standing forecast without scoring it is the honest outcome; a forecast
* nobody was looking at when the period arrived is not a wrong forecast.
*
* ## Every imported start is marked as imported
*
* [PeriodRecordSource.IMPORTED], whatever the file says the source was.
* The provenance in the file is a fact about another phone; what this phone
* can vouch for is that the record came out of an archive. §14 how a
* record arrived is part of the record.
*
* ## Merge keeps what is on this phone
*
* A start date already recorded here is left exactly as it is not
* updated, not merged field-by-field. The device record is the one the user
* made on the device she is holding, and silently rewriting its end date or
* its source from a file would be the modification §14 forbids. The count
* comes back so she can be told plainly how many were already here.
*/
suspend fun importHistory(
periods: List<PeriodRecord>,
spotting: List<SpottingRecord>,
mode: ImportMode,
): ImportSummary = with(db) {
// One transaction either way. `replaceEverything` wipes inside it, for
// the reason its KDoc gives: a failure between the wipe and the writes
// would leave the user with neither the history she had nor the one in
// her file.
val write: suspend () -> ImportSummary = {
val now = clock.instant()
var periodsAdded = 0
var periodsPresent = 0
// Ascending, so the sequence written to the table matches the sequence
// she lived. Nothing downstream depends on insertion order — but a
// reader of the raw table should not have to sort it to see a history.
for (period in periods.sortedBy { it.startDate }) {
if (periodDao.byStartDate(period.startDate) != null) {
periodsPresent++
continue
}
periodDao.insert(
PeriodRecord(
id = 0,
startDate = period.startDate,
endDate = period.endDate,
source = PeriodRecordSource.IMPORTED,
isConfirmed = period.isConfirmed,
).toEntity(now, now),
)
periodsAdded++
}
var spottingAdded = 0
var spottingPresent = 0
for (day in spotting.sortedBy { it.date }) {
// IGNORE on conflict returns -1 rather than throwing, so the
// UNIQUE index does the checking and there is no read per day.
val id = spottingDao.insert(SpottingRecordEntity(date = day.date, createdAt = now))
if (id > 0) spottingAdded++ else spottingPresent++
}
// Once. Not per record.
snapshotForecast(basedOnPeriodId = null)
ImportSummary(periodsAdded, periodsPresent, spottingAdded, spottingPresent)
}
if (mode == ImportMode.REPLACE) replaceEverything(write) else inTransaction(write)
}
// -----------------------------------------------------------------------
// Internals
// -----------------------------------------------------------------------

View File

@ -0,0 +1,32 @@
package dev.privacyllc.period.core.data
/**
* What to do with the history already on this phone.
*
* There is no third option and no default. Guessing between these is
* guessing whether the user is restoring a phone she lost or merging a
* phone she still has, and only she knows which.
*/
enum class ImportMode {
/** Keep what is here; add only what is not. */
MERGE,
/** Erase everything recorded on this phone first, and take the file as the record. */
REPLACE,
}
/**
* What an import did, in numbers the user can be shown.
*
* Counts, never dates: this value travels through a ViewModel and into a
* message, which is exactly the path `PeriodRecord.toString` is dateless to
* protect.
*/
data class ImportSummary(
val periodsAdded: Int,
val periodsAlreadyRecorded: Int,
val spottingAdded: Int,
val spottingAlreadyRecorded: Int,
) {
val addedAnything: Boolean get() = periodsAdded > 0 || spottingAdded > 0
}

View File

@ -0,0 +1,269 @@
package dev.privacyllc.period.core.data
import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import dev.privacyllc.period.core.database.PeriodDatabase
import dev.privacyllc.period.domain.cycle.PeriodRecord
import dev.privacyllc.period.domain.cycle.PeriodRecordSource
import dev.privacyllc.period.domain.cycle.SpottingRecord
import dev.privacyllc.period.domain.prediction.BaselinePredictionEngine
import dev.privacyllc.period.domain.prediction.Prediction
import dev.privacyllc.period.domain.prediction.PredictionEngine
import dev.privacyllc.period.domain.prediction.PredictionInput
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
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.annotation.Config
import java.time.Clock
import java.time.LocalDate
import java.time.ZoneOffset
/**
* Reading an archive back in.
*
* The export was a one-way door, and the danger in closing it is not that a
* record fails to arrive it is that a file arrives carrying more authority
* than it has. An import is a claim about a history this app never watched
* happen, so these tests are mostly about what it must *not* be allowed to do:
* score a forecast, run the engine once per record, or quietly rewrite what the
* user entered on the phone she is holding.
*/
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class CycleRepositoryImportTest {
/** Counts how often the forecast was recalculated. §11's "once, at the end". */
private class CountingEngine(
private val delegate: PredictionEngine = BaselinePredictionEngine(),
) : PredictionEngine {
var calls = 0
private set
/** Set to make the next recalculation fail, standing in for anything that can. */
var failNext = false
override val modelVersion: String get() = delegate.modelVersion
override fun predict(input: PredictionInput): Prediction? {
calls++
if (failNext) {
failNext = false
throw IllegalStateException("the forecast could not be built")
}
return delegate.predict(input)
}
}
private lateinit var db: PeriodDatabase
private lateinit var engine: CountingEngine
private lateinit var repo: CycleRepository
private val today = LocalDate.of(2026, 8, 20)
@Before fun open() {
db = Room.inMemoryDatabaseBuilder(
ApplicationProvider.getApplicationContext(),
PeriodDatabase::class.java,
).allowMainThreadQueries().build()
engine = CountingEngine()
repo = CycleRepository(
db,
engine,
Clock.fixed(today.atStartOfDay(ZoneOffset.UTC).toInstant(), ZoneOffset.UTC),
)
}
@After fun close() = db.close()
private fun period(start: String, end: String? = null, source: PeriodRecordSource = PeriodRecordSource.MANUAL) =
PeriodRecord(
id = 99, // The file has no ids; a caller's leftover must not survive.
startDate = LocalDate.parse(start),
endDate = end?.let(LocalDate::parse),
source = source,
)
private fun archive() = listOf(
period("2026-04-22", "2026-04-27"),
period("2026-05-21", "2026-05-25"),
period("2026-06-19", "2026-06-24"),
period("2026-07-16"),
)
// -----------------------------------------------------------------------
// What arrives
// -----------------------------------------------------------------------
@Test fun `an archive comes back whole`() = runTest {
val summary = repo.importHistory(
archive(),
listOf(SpottingRecord(7, LocalDate.of(2026, 5, 16))),
ImportMode.MERGE,
)
assertEquals(4, summary.periodsAdded)
assertEquals(1, summary.spottingAdded)
val back = repo.confirmedPeriods.first()
assertEquals(archive().map { it.startDate }, back.map { it.startDate })
assertEquals(LocalDate.of(2026, 4, 27), back.first().endDate)
assertNull("the open period must still be open", back.last().endDate)
// Rule 5 of the format, enforced here too: ids are this phone's.
assertTrue(back.none { it.id == 99L })
}
@Test fun `every imported record says it was imported`() = runTest {
repo.importHistory(archive(), emptyList(), ImportMode.MERGE)
// Not MANUAL, whatever the file claimed. What this phone can vouch for
// is that the record came out of an archive — §14.
assertTrue(repo.confirmedPeriods.first().all { it.source == PeriodRecordSource.IMPORTED })
}
// -----------------------------------------------------------------------
// The forecast: once, and never scored
// -----------------------------------------------------------------------
@Test fun `the forecast is recalculated exactly once, however long the archive`() = runTest {
val before = engine.calls
repo.importHistory(archive(), emptyList(), ImportMode.MERGE)
// Four records. One recalculation — not four, and not zero: the
// forecast on screen would otherwise still be the one from before the
// import, which is a forecast about a history that no longer exists.
assertEquals(1, engine.calls - before)
}
@Test fun `an import scores nothing`() = runTest {
// A standing forecast, made today, about a period still to come.
repo.confirmPeriodStart(LocalDate.of(2026, 8, 1))
assertEquals(0, repo.accuracy.first().scoredCount)
// An archive whose newest record is dated TODAY — the shape the backfill
// guard alone does not catch, because a file exported this morning
// carries this morning's period.
repo.importHistory(
listOf(period("2026-06-19", "2026-06-24"), period(today.toString())),
emptyList(),
ImportMode.MERGE,
)
// Nothing an archive contains was ever predicted to the user. Counting
// any of it fills §16's figures with numbers the app made up about
// itself.
assertEquals(0, repo.accuracy.first().scoredCount)
}
@Test fun `an import leaves exactly one standing forecast`() = runTest {
repo.confirmPeriodStart(LocalDate.of(2026, 8, 1))
repo.importHistory(archive(), emptyList(), ImportMode.MERGE)
// The invariant scoreOutstanding depends on: one unscored snapshot at
// any moment, so a later start is unambiguous about what it answers.
assertEquals(1, db.predictionRecordDao().unscored().size)
}
// -----------------------------------------------------------------------
// Merge, and replace
// -----------------------------------------------------------------------
@Test fun `merge keeps the record this phone already had`() = runTest {
repo.confirmPeriodStart(LocalDate.of(2026, 7, 16))
val summary = repo.importHistory(archive(), emptyList(), ImportMode.MERGE)
assertEquals(3, summary.periodsAdded)
assertEquals(1, summary.periodsAlreadyRecorded)
// Untouched: still hers, still MANUAL, still open. Rewriting it from a
// file would be the silent modification §14 forbids.
val kept = repo.confirmedPeriods.first().single { it.startDate == LocalDate.of(2026, 7, 16) }
assertEquals(PeriodRecordSource.MANUAL, kept.source)
assertEquals(4, repo.confirmedPeriods.first().size)
}
@Test fun `merge does not duplicate a spotting day already recorded`() = runTest {
repo.recordSpotting(LocalDate.of(2026, 5, 16))
val summary = repo.importHistory(
emptyList(),
listOf(SpottingRecord(1, LocalDate.of(2026, 5, 16)), SpottingRecord(2, LocalDate.of(2026, 7, 13))),
ImportMode.MERGE,
)
assertEquals(1, summary.spottingAdded)
assertEquals(1, summary.spottingAlreadyRecorded)
assertEquals(2, repo.spotting.first().size)
}
@Test fun `replace takes the file as the record`() = runTest {
repo.confirmPeriodStart(LocalDate.of(2026, 8, 1))
repo.recordSpotting(LocalDate.of(2026, 8, 3))
repo.recordNotYet(LocalDate.of(2026, 8, 19))
repo.importHistory(archive(), emptyList(), ImportMode.REPLACE)
assertEquals(archive().map { it.startDate }, repo.confirmedPeriods.first().map { it.startDate })
assertTrue(repo.spotting.first().isEmpty())
// "Not yet" answers a question this phone asked about a history that is
// now gone. Leaving them would censor a forecast built from a different
// history entirely.
assertTrue(repo.notYetObservations.first().isEmpty())
}
@Test fun `replace cannot leave the user with neither history`() = runTest {
repo.confirmPeriodStart(LocalDate.of(2026, 8, 1))
// Something fails after the wipe and after the rows are in — the last
// step of the import rather than the first.
engine.failNext = true
runCatching { repo.importHistory(archive(), emptyList(), ImportMode.REPLACE) }
// One transaction: the wipe rolls back with it, so she still has what
// she started with. This is the property `replaceEverything` exists
// for — take the `withTransaction` off it and only this test goes red.
assertEquals(
listOf(LocalDate.of(2026, 8, 1)),
repo.confirmedPeriods.first().map { it.startDate },
)
}
@Test fun `a file naming the same day twice is not a crash`() = runTest {
// Nothing stops a person editing an archive by hand, and the UNIQUE
// index would abort the second insert — taking the whole import with it.
val summary = repo.importHistory(
listOf(period("2026-06-19"), period("2026-06-19", "2026-06-24")),
listOf(SpottingRecord(1, LocalDate.of(2026, 7, 13)), SpottingRecord(2, LocalDate.of(2026, 7, 13))),
ImportMode.MERGE,
)
// The first wins; the second is counted as already recorded, exactly as
// if this phone had held it.
assertEquals(1, summary.periodsAdded)
assertEquals(1, summary.periodsAlreadyRecorded)
assertEquals(1, summary.spottingAdded)
assertEquals(1, summary.spottingAlreadyRecorded)
assertNull(repo.confirmedPeriods.first().single().endDate)
}
// -----------------------------------------------------------------------
// Nothing to do
// -----------------------------------------------------------------------
@Test fun `an empty archive changes nothing and still leaves a forecast standing`() = runTest {
repo.confirmPeriodStart(LocalDate.of(2026, 8, 1))
val summary = repo.importHistory(emptyList(), emptyList(), ImportMode.MERGE)
assertEquals(ImportSummary(0, 0, 0, 0), summary)
assertEquals(1, repo.confirmedPeriods.first().size)
assertEquals(1, db.predictionRecordDao().unscored().size)
}
}

View File

@ -55,6 +55,41 @@ abstract class PeriodDatabase : RoomDatabase() {
clearAllTables()
}
/**
* Empty every table, then [block], in **one** transaction.
*
* For importing an archive with "replace what is here" chosen. The wipe and
* the writes have to succeed or fail together: a failure between them leaves
* the user with neither the history she had nor the one in her file, and
* there is no way back from that.
*
* That is why this is not [deleteEverything]. `clearAllTables()` is not a
* statement you may nest: the `performClear` it compiles to opens with
* `assertNotMainThread()` and `assertNotSuspendingTransaction()` and then
* runs its work through `runBlockingUninterruptible` a blocking coroutine
* of its own and it finishes with a `VACUUM`, which SQLite refuses inside
* a transaction (checked against the room-runtime 2.8.4 bytecode, not the
* documentation). On this Room version under Robolectric the assertion
* happens not to fire and the rollback happens to come out right; that is a
* property of which driver is in play, not a contract, and an import built
* on it would fail on somebody's phone rather than in a test. Four DELETE
* statements are inside the transaction by construction, on any driver.
*
* What is given up is the `VACUUM` [deleteEverything] does the old rows
* stay in the file's free pages until something reuses them. That is honest
* for what this is: the user asked to replace her history, not to erase it.
* Delete My Data is the control that erases, and it still does.
*/
suspend fun <R> replaceEverything(block: suspend () -> R): R = withTransaction {
// Children before parents in spirit, though nothing here is a foreign
// key: the two tables that reference a period by date or id go first.
notYetObservationDao().deleteAll()
predictionRecordDao().deleteAll()
spottingRecordDao().deleteAll()
periodRecordDao().deleteAll()
block()
}
/**
* Run [block] in one transaction.
*

View File

@ -18,14 +18,20 @@ import java.time.LocalDate
* the UI renders it, and the whole point of this product is that logging a
* period updates the forecast on screen without anybody refreshing anything.
*
* Note there is no `deleteAll`-by-convenience anywhere except the explicit
* `deleteEverything` on the database itself: Delete My Data is an irreversible
* operation the user confirms, not something a DAO offers casually.
* There is no `deleteAll`-by-convenience here. Emptying a table is an
* irreversible operation the user confirms in words first, so the ones that
* exist name their callers:
*
* One documented exception: [NotYetObservationDao.deleteAll]. Every observation
* censors the same single question, so a confirmed start resolves all of them at
* once by definition see the comment there for why a date-bounded delete could
* not express that.
* - [NotYetObservationDao.deleteAll] every observation censors the same
* single question, so a confirmed start resolves all of them at once by
* definition. See the comment there for why a date-bounded delete could not
* express that.
* - [PeriodRecordDao.deleteAll], [SpottingRecordDao.deleteAll] and
* [PredictionRecordDao.deleteAll] `PeriodDatabase.replaceEverything`, and
* nothing else. Delete My Data calls `deleteEverything` instead, which also
* reclaims the pages; a *replace* cannot, because it has to sit inside the
* same transaction as the writes that follow it and `clearAllTables` ends
* the transaction it is called in.
*/
@Dao
interface PeriodRecordDao {
@ -75,6 +81,10 @@ interface PeriodRecordDao {
*/
@Query("SELECT startDate FROM period_records WHERE isConfirmed = 1 ORDER BY startDate ASC")
suspend fun confirmedStartDates(): List<LocalDate>
/** `replaceEverything` only — see this file's header. */
@Query("DELETE FROM period_records")
suspend fun deleteAll()
}
@Dao
@ -88,6 +98,10 @@ interface SpottingRecordDao {
@Query("DELETE FROM spotting_records WHERE date = :date")
suspend fun deleteByDate(date: LocalDate)
/** `replaceEverything` only — see this file's header. */
@Query("DELETE FROM spotting_records")
suspend fun deleteAll()
}
@Dao
@ -124,6 +138,10 @@ interface PredictionRecordDao {
@Query("DELETE FROM prediction_records WHERE actualStartDate IS NULL")
suspend fun deleteUnscored()
/** `replaceEverything` only — see this file's header. */
@Query("DELETE FROM prediction_records")
suspend fun deleteAll()
@Insert(onConflict = OnConflictStrategy.ABORT)
suspend fun insert(record: PredictionRecordEntity): Long

View File

@ -0,0 +1,141 @@
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
import java.time.format.DateTimeFormatter
/**
* What an export file contains, once it has been read back.
*
* Ids are absent deliberately: an imported record is a new record here, not the
* one it was on another phone, and pretending otherwise invites two devices to
* disagree about which row a number refers to.
*/
data class ParsedExport(
val exportedOn: LocalDate,
val appVersion: String,
val periods: List<PeriodRecord>,
val spotting: List<SpottingRecord>,
val settings: ExportedSettings,
)
/** Why a file could not be read, in words a screen can show. */
sealed interface ImportFailure {
/** Not this app's format at all — someone else's JSON, or not JSON. */
data object NotOurFile : ImportFailure
/** This app's format, from a version that writes something this build cannot read. */
data class NewerFormat(val version: Long) : ImportFailure
/** Ours, and damaged. */
data object Damaged : ImportFailure
}
/**
* Read back a file [ExportDocument] wrote.
*
* ## Why this exists at all
*
* The export was a one-way door: the app offered a copy of everything and
* nothing could take it back. A user changing phone started the prediction model
* from zero, which makes "your history is yours" a smaller promise than it
* sounds.
*
* ## What it refuses
*
* Anything it cannot be certain about. A missing magic string, a format version
* from the future, a damaged file each returns a failure rather than a partial
* import, because a half-read cycle history is worse than none: it looks like
* data and predicts like noise.
*
* Unknown *keys* are ignored, per the format contract's rule 2 that is what
* lets a later version add fields without breaking this reader. Unknown
* **values** are not: a `source` this build does not recognise is a record whose
* provenance it cannot honour, so it becomes the honest fallback rather than a
* guess.
*/
object ExportReader {
private val DATE = DateTimeFormatter.ISO_LOCAL_DATE
private val TIME = DateTimeFormatter.ofPattern("HH:mm")
fun read(text: String): Result<ParsedExport> {
val root = runCatching { Json.parse(text).obj() }.getOrElse {
// Text that will not parse at all still gets the more useful of the
// two answers. A file truncated mid-share keeps its opening keys,
// so the magic string appearing anywhere in it is the only evidence
// available that this WAS ours — and "this is damaged" sends the
// user looking for a better copy, where "this isn't from this app"
// sends her away from the file she actually needs.
return Result.failure(
ImportException(
if (text.contains(ExportDocument.FORMAT)) ImportFailure.Damaged
else ImportFailure.NotOurFile,
),
)
}
// The magic string first: anything else is somebody else's file, and
// saying so is more useful than a parse error about a missing key.
val format = runCatching { root.string("format") }.getOrNull()
if (format != ExportDocument.FORMAT) {
return Result.failure(ImportException(ImportFailure.NotOurFile))
}
val version = runCatching { root.long("formatVersion") }.getOrElse {
return Result.failure(ImportException(ImportFailure.Damaged))
}
if (version > ExportDocument.FORMAT_VERSION) {
// Rule 2 lets a newer version add keys, which this reader would
// ignore safely — but a bumped version means something was removed,
// retyped, or given a new meaning, and none of those are safe to
// guess at.
return Result.failure(ImportException(ImportFailure.NewerFormat(version)))
}
return runCatching {
ParsedExport(
exportedOn = LocalDate.parse(root.string("exportedOn"), DATE),
appVersion = root.string("appVersion"),
periods = root.field("periods").arr().items.map { readPeriod(it.obj()) },
spotting = root.field("spotting").arr().items.map {
SpottingRecord(id = 0, date = LocalDate.parse(it.obj().string("date"), DATE))
},
settings = readSettings(root.field("settings").obj()),
)
}.recoverCatching { throw ImportException(ImportFailure.Damaged) }
}
private fun readPeriod(o: JsonValue.Obj) = PeriodRecord(
id = 0,
startDate = LocalDate.parse(o.string("startDate"), DATE),
endDate = o.stringOrNull("endDate")?.let { LocalDate.parse(it, DATE) },
// A source this build does not know becomes IMPORTED rather than a
// guess: the record is real, its provenance is not knowable here.
source = PeriodRecordSource.entries.firstOrNull { it.name == o.string("source") }
?: PeriodRecordSource.IMPORTED,
isConfirmed = o.bool("confirmed"),
)
private fun readSettings(o: JsonValue.Obj): ExportedSettings {
val reminders = o.field("reminders").obj()
return ExportedSettings(
notificationPrivacy = o.string("notificationPrivacy"),
reminderTime = LocalTime.parse(o.string("reminderTime"), TIME),
periodApproaching = reminders.bool("periodApproaching"),
periodExpectedToday = reminders.bool("periodExpectedToday"),
didItStart = reminders.bool("didItStart"),
periodEndCheckIn = reminders.bool("periodEndCheckIn"),
fertileWindow = reminders.bool("fertileWindow"),
ovulation = reminders.bool("ovulation"),
biometricUnlock = o.bool("biometricUnlock"),
theme = o.string("theme"),
)
}
}
/** Carries an [ImportFailure] out of the reader without it being a bare message. */
class ImportException(val failure: ImportFailure) : Exception(failure.toString())

View File

@ -0,0 +1,199 @@
package dev.privacyllc.period.core.export
/**
* Just enough JSON to read a file this app wrote.
*
* ## Why not a library
*
* `core/export` is a pure-JVM module with two dependencies, and its own KDoc
* explains that the narrowness is the point: the export format is a contract
* with people's archives, and a module that cannot reach a database or a device
* cannot accidentally put either in the file. Adding a serialization runtime and
* its compiler plugin to read back a document this app renders itself, in one
* function, pinned byte-for-byte by a golden file, is a permanent cost for a
* bounded problem.
*
* ## Why it is strict
*
* It refuses rather than guesses. A parser that quietly accepts trailing commas,
* unquoted keys or duplicated fields is a parser that will one day accept
* somebody else's file and import it as a cycle history. Everything unexpected
* throws [JsonException], and the caller turns that into "this is not a file
* from this app".
*
* Not a general-purpose parser and not offered as one: no unicode escapes beyond
* the ones this app writes, no exponent notation, no comments.
*/
internal class JsonException(message: String) : Exception(message)
internal sealed interface JsonValue {
data class Obj(val entries: Map<String, JsonValue>) : JsonValue
data class Arr(val items: List<JsonValue>) : JsonValue
data class Str(val value: String) : JsonValue
data class Num(val value: Long) : JsonValue
data class Bool(val value: Boolean) : JsonValue
data object Null : JsonValue
}
internal object Json {
fun parse(text: String): JsonValue {
val reader = Reader(text)
reader.skipWhitespace()
val value = reader.readValue()
reader.skipWhitespace()
if (!reader.atEnd()) throw JsonException("trailing content after the document")
return value
}
private class Reader(private val text: String) {
private var at = 0
fun atEnd() = at >= text.length
fun skipWhitespace() {
while (at < text.length && text[at].isWhitespace()) at++
}
fun readValue(): JsonValue {
if (atEnd()) throw JsonException("the document ends where a value was expected")
return when (text[at]) {
'{' -> readObject()
'[' -> readArray()
'"' -> JsonValue.Str(readString())
't', 'f' -> readBoolean()
'n' -> readNull()
else -> readNumber()
}
}
private fun readObject(): JsonValue.Obj {
expect('{')
val entries = LinkedHashMap<String, JsonValue>()
skipWhitespace()
if (peek() == '}') { at++; return JsonValue.Obj(entries) }
while (true) {
skipWhitespace()
val key = readString()
// A duplicated key means two answers to one question, and
// silently taking the last is how a reader disagrees with the
// writer about what a file says.
if (entries.containsKey(key)) throw JsonException("the key \"$key\" appears twice")
skipWhitespace()
expect(':')
skipWhitespace()
entries[key] = readValue()
skipWhitespace()
when (peek()) {
',' -> at++
'}' -> { at++; return JsonValue.Obj(entries) }
else -> throw JsonException("expected , or } in an object")
}
}
}
private fun readArray(): JsonValue.Arr {
expect('[')
val items = mutableListOf<JsonValue>()
skipWhitespace()
if (peek() == ']') { at++; return JsonValue.Arr(items) }
while (true) {
skipWhitespace()
items += readValue()
skipWhitespace()
when (peek()) {
',' -> at++
']' -> { at++; return JsonValue.Arr(items) }
else -> throw JsonException("expected , or ] in an array")
}
}
}
private fun readString(): String {
expect('"')
val out = StringBuilder()
while (true) {
if (atEnd()) throw JsonException("a string is never closed")
when (val c = text[at++]) {
'"' -> return out.toString()
'\\' -> {
if (atEnd()) throw JsonException("a string ends in an escape")
when (val e = text[at++]) {
'"' -> out.append('"')
'\\' -> out.append('\\')
'/' -> out.append('/')
'n' -> out.append('\n')
't' -> out.append('\t')
'r' -> out.append('\r')
'b' -> out.append('\b')
else -> throw JsonException("unsupported escape \\$e")
}
}
else -> out.append(c)
}
}
}
private fun readBoolean(): JsonValue.Bool = when {
text.startsWith("true", at) -> { at += 4; JsonValue.Bool(true) }
text.startsWith("false", at) -> { at += 5; JsonValue.Bool(false) }
else -> throw JsonException("expected true or false")
}
private fun readNull(): JsonValue {
if (!text.startsWith("null", at)) throw JsonException("expected null")
at += 4
return JsonValue.Null
}
private fun readNumber(): JsonValue.Num {
val start = at
if (peek() == '-') at++
while (at < text.length && text[at].isDigit()) at++
if (at == start) throw JsonException("expected a value")
// Integers only: the format writes one number, formatVersion, and a
// reader that accepted 1.0 or 1e0 would be accepting a shape this
// app never produces.
if (at < text.length && (text[at] == '.' || text[at] == 'e' || text[at] == 'E')) {
throw JsonException("only whole numbers are supported")
}
return JsonValue.Num(text.substring(start, at).toLong())
}
private fun peek(): Char =
if (atEnd()) throw JsonException("the document ends unexpectedly") else text[at]
private fun expect(c: Char) {
if (peek() != c) throw JsonException("expected $c")
at++
}
}
}
// -- Small readers, so the document parser reads like the document ------------
internal fun JsonValue.obj(): JsonValue.Obj =
this as? JsonValue.Obj ?: throw JsonException("expected an object")
internal fun JsonValue.arr(): JsonValue.Arr =
this as? JsonValue.Arr ?: throw JsonException("expected an array")
internal fun JsonValue.Obj.field(name: String): JsonValue =
entries[name] ?: throw JsonException("missing \"$name\"")
internal fun JsonValue.Obj.string(name: String): String =
(field(name) as? JsonValue.Str)?.value ?: throw JsonException("\"$name\" is not a string")
internal fun JsonValue.Obj.stringOrNull(name: String): String? = when (val v = field(name)) {
is JsonValue.Str -> v.value
JsonValue.Null -> null
else -> throw JsonException("\"$name\" is not a string or null")
}
internal fun JsonValue.Obj.bool(name: String): Boolean =
(field(name) as? JsonValue.Bool)?.value ?: throw JsonException("\"$name\" is not true or false")
internal fun JsonValue.Obj.long(name: String): Long =
(field(name) as? JsonValue.Num)?.value ?: throw JsonException("\"$name\" is not a number")

View File

@ -0,0 +1,139 @@
package dev.privacyllc.period.core.export
import dev.privacyllc.period.domain.cycle.PeriodRecordSource
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import java.time.LocalDate
/**
* The export was a one-way door until this test existed.
*
* `ExportFormatTest` pins what the file looks like; this pins that the file is
* still *the record* that everything the user is told she is keeping can be
* handed back. The two together are the whole promise: "keep this file, a
* future version will read it back" is a sentence in the archive itself
* (`ExportDocument.ABOUT`), and until now nothing checked it was true.
*/
class ExportRoundTripTest {
private fun read(text: String) = ExportReader.read(text).getOrThrow()
private fun failure(text: String): ImportFailure =
(ExportReader.read(text).exceptionOrNull() as ImportException).failure
// -----------------------------------------------------------------------
// The round trip
// -----------------------------------------------------------------------
@Test fun `every period survives the round trip, in order`() {
val back = read(ExportFixture.render()).periods
// Rule 4: the file ascends, so what comes back ascends — the fixture is
// deliberately supplied out of order.
assertEquals(ExportFixture.periods.map { it.startDate }.sorted(), back.map { it.startDate })
ExportFixture.periods.sortedBy { it.startDate }.zip(back).forEach { (before, after) ->
assertEquals(before.startDate, after.startDate)
assertEquals(before.endDate, after.endDate)
assertEquals(before.source, after.source)
assertEquals(before.isConfirmed, after.isConfirmed)
// Rule 5: ids are not in the file and are not invented here. An
// imported record is a new row on this phone.
assertEquals(0L, after.id)
}
// The open period is still open — the shape most likely to be lost.
assertNull(back.single { it.startDate == LocalDate.of(2026, 7, 16) }.endDate)
}
@Test fun `every spotting day survives the round trip, in order`() {
val back = read(ExportFixture.render()).spotting
assertEquals(ExportFixture.spotting.map { it.date }.sorted(), back.map { it.date })
assertTrue(back.all { it.id == 0L })
}
@Test fun `every setting survives the round trip`() {
// The whole data class, field by field, by equality — so a field added
// to ExportedSettings and rendered but never read fails here.
assertEquals(ExportFixture.settings, read(ExportFixture.render()).settings)
}
@Test fun `the file says when it was written and by what`() {
val parsed = read(ExportFixture.render())
assertEquals(ExportFixture.EXPORTED_ON, parsed.exportedOn)
assertEquals(ExportFixture.APP_VERSION, parsed.appVersion)
}
@Test fun `the committed golden file still reads`() {
// The real point of the golden file: not that today's renderer matches
// it, but that a file somebody exported from the shipped version opens
// on a phone running a later one.
val golden = checkNotNull(javaClass.classLoader!!.getResourceAsStream("golden-v1.json"))
.bufferedReader().readText()
val parsed = read(golden)
assertEquals(4, parsed.periods.size)
assertEquals(2, parsed.spotting.size)
assertEquals(ExportFixture.settings, parsed.settings)
}
// -----------------------------------------------------------------------
// What it refuses, and how it says so
// -----------------------------------------------------------------------
@Test fun `somebody else's json is not read as a cycle history`() {
assertEquals(ImportFailure.NotOurFile, failure("""{"cycles":[{"start":"2026-01-01"}]}"""))
assertEquals(ImportFailure.NotOurFile, failure("not json at all"))
assertEquals(ImportFailure.NotOurFile, failure(""))
// A file whose magic string is almost right is still not ours.
assertEquals(
ImportFailure.NotOurFile,
failure("""{"format":"privacy-period-tracker-export-v2","formatVersion":1}"""),
)
}
@Test fun `a file from a newer version is refused rather than half-read`() {
val newer = ExportFixture.render().replace(""""formatVersion": 1""", """"formatVersion": 2""")
// Rule 2: a bump means something was removed, retyped or given a new
// meaning. Reading the keys this build happens to recognise would
// import a history that is quietly wrong.
assertEquals(ImportFailure.NewerFormat(2), failure(newer))
}
@Test fun `a damaged file is refused rather than partly imported`() {
val whole = ExportFixture.render()
// Truncated mid-write — the shape a half-finished share produces.
assertEquals(ImportFailure.Damaged, failure(whole.substring(0, whole.length / 2)))
// Ours, and missing something it promised (rule 3: every key present).
assertEquals(ImportFailure.Damaged, failure(whole.replace(""""startDate"""", """"startDay"""")))
// Ours, with a date that is not a date.
assertEquals(ImportFailure.Damaged, failure(whole.replace("2026-06-19", "19/06/2026")))
// Ours, with a boolean where a boolean was promised.
assertEquals(ImportFailure.Damaged, failure(whole.replace(""""confirmed": false""", """"confirmed": "no"""")))
}
// -----------------------------------------------------------------------
// Forward compatibility, which is rule 2's other half
// -----------------------------------------------------------------------
@Test fun `a key this build has never heard of is ignored`() {
val withExtra = ExportFixture.render()
.replace(""""confirmed": true""", """"confirmed": true, "flow": "HEAVY"""")
// Rule 2 is what lets a later version add fields. If this reader
// refused unknown keys, the format could never grow without stranding
// every older install.
assertEquals(4, read(withExtra).periods.size)
}
@Test fun `a source this build cannot name becomes an import, not a guess`() {
val unknown = ExportFixture.render().replace(""""source": "MANUAL"""", """"source": "WATCH"""")
val record = read(unknown).periods.single { it.startDate == LocalDate.of(2026, 5, 21) }
// The period is real; how it was entered is not knowable here. MANUAL
// would be a claim about the user that this build cannot support.
assertEquals(PeriodRecordSource.IMPORTED, record.source)
}
}

View File

@ -109,6 +109,36 @@ lets it be pinned byte-for-byte against a committed golden file — the stronges
protection available for a format that, as #35 puts it, "outlives this batch:
whatever ships first is what people's archives will be in".
### Reading the file back lives in the same module, and refuses more than it accepts
`ExportDocument.render` had no counterpart until #58, which made the archive a
one-way door: a user changing phone started the model from zero, and "your
history is yours" was a smaller promise than it sounded. `ExportReader.read`
mirrors it, in the same module, so the two halves of the format cannot drift
into separate places.
The JSON is parsed by `Json.kt`, hand-written and strict, for the same reason
the renderer is hand-written: `core/export` has two dependencies and their
narrowness is the point, `kotlinx-serialization` is not in the version catalog,
and the format is one function's output pinned by a golden file. Strict means it
refuses duplicate keys, trailing content, non-integer numbers and unsupported
escapes — a parser that quietly accepts a trailing comma is a parser that will
one day accept somebody else's file and import it as a cycle history.
What the reader refuses is the design:
| The file | The answer |
| --- | --- |
| no `format` magic string, or the wrong one | `NotOurFile` — and nothing is read |
| a `formatVersion` above this build's | `NewerFormat` — rule 2 lets a newer version *add* keys, but a bump means something was removed, retyped or given a new meaning |
| ours, and damaged or truncated | `Damaged` |
| a key this build has never heard of | **ignored** — that is rule 2's other half, and what lets the format grow |
| a `source` this build cannot name | `IMPORTED`, never a guess |
Ids are deliberately absent from the parsed result. An imported record is a new
row on this phone, and pretending otherwise invites two devices to disagree
about which row a number refers to.
### Why `core/security` depends on nothing
It holds key material, and the rule that follows from that is the one worth
@ -152,6 +182,42 @@ carry on from.
record already holds is a question only the user can settle, and merging would
delete a period they entered.
### An import is a write, and it is not a loop over `confirmPeriodStart`
`CycleRepository.importHistory` is its own path for three reasons, each of which
is something `confirmPeriodStart` does that would be wrong for an archive:
- it **scores the standing forecast**, and every record in an archive is
history the app never forecast — §16's "your predictions are getting better"
would fill with figures the app invented about itself. The backfill guard in
`scoreOutstanding` catches most of that by date but not all of it: a file
exported this morning carries a period starting today. So scoring is not
dodged by accident of date here, it is not reached;
- it **clears the "not yet" observations** a start resolves, which answer a
question *this* phone asked;
- it **snapshots a forecast per record**, so a thousand-record archive would
run the engine a thousand times over histories that never existed.
What it keeps is the part that matters: one transaction, through the same
insert, behind the same UNIQUE start-date constraint, inside the repository —
nothing above this module reaches a DAO. The forecast is recalculated exactly
once, at the end, inheriting the standing lineage origin, because the question
(*when does the next period begin?*) has not changed — only what is known about
it.
Merge keeps what is on this phone: a start date already recorded here is left
exactly as it is, not merged field by field, because silently rewriting its end
date or its source from a file is the modification §14 forbids. Replace goes
through `PeriodDatabase.replaceEverything`, which empties the four tables inside
the *same* transaction as the writes — a failure between the wipe and the
inserts would leave the user with neither the history she had nor the one in her
file. That is why it is not `deleteEverything`: `clearAllTables` asserts it is
not inside a suspending transaction, runs its work in a blocking coroutine of
its own and finishes with a `VACUUM` that SQLite forbids inside a transaction.
Delete My Data still calls `deleteEverything`, and still gets the `VACUUM`
replacing a history is not erasing one, and only one of them is a privacy
control.
The ViewModel also installs a `CoroutineExceptionHandler` as a backstop. In a
health app a crash mid-write is adjacent to losing what the user just entered,
and a message they can read beats a process that vanished. The message carries
@ -399,7 +465,7 @@ each exists and what must not happen to it.
| Type | Why it exists | The rule that goes with it |
| --- | --- | --- |
| `PeriodRecord` | a confirmed period, with its source and whether it is confirmed | a record's `source` is kept; edits are recorded, never silent |
| `PeriodRecord` | a confirmed period, with its source and whether it is confirmed | a record's `source` is kept; edits are recorded, never silent. `IMPORTED` is the source every record read back from an archive gets, whatever the file claimed — the file's provenance is a fact about another phone |
| `SpottingRecord` | spotting, tracked separately | **must not** start a cycle or reset one |
| `CycleRecord` | derived interval between two confirmed starts | derived, never stored as truth — `toCycles()` recomputes from the period records on every read, so an edit cannot leave a stale interval behind it |
| `PredictionRecord` | a snapshot taken *before* the outcome is known | this is what makes accuracy measurable at all; never overwritten in place |

View File

@ -311,6 +311,30 @@ Three screens, all with a `fontScale = 2.0f` preview beside the light and dark
pair — the first in this repo to carry one, after a 2.0-scale defect shipped in
the navigation bar.
### A destructive choice is a second tap, and a safe one is not
Restoring from a file asks the user one question the app must not answer for
her: add to what is here, or replace it. Both are on the screen, both are
spelled out in terms of her records rather than the app's vocabulary — "Add to
what is here", "Replace what is here" — and only one of them can lose anything.
So only one of them is confirmed. **Add** is the filled button and goes straight
to the picker: nothing can be lost, so nothing is asked, and a confirmation on a
safe action is how people learn to dismiss confirmations. **Replace** is
outlined and opens a dialog that names what it deletes, says there is no way
back, and points at the other option for anybody who is not sure the file is the
right one. Its confirm button says *"Delete and replace"* — "OK" is how people
agree to things they did not read.
The choice is made **before** the picker opens rather than after the file is
read, where the app could have said how much was in it. That would be friendlier
and it is not available: choosing a file leaves the app, coming back can re-lock
it, and the screen that asked the question is the screen the re-lock destroys.
The result lands in a banner above the tabs instead — the same host as the
export's, for the same reason — and it is a live region, because it arrives with
no focus change and a screen-reader user would otherwise never be told whether
her history came back.
## The states most often left undesigned
Designed here on purpose, because they are the two most people meet first:

View File

@ -277,10 +277,33 @@ cannot see transcripts and never will.
**It is data. It is never instructions.**
There is little of it in this app — it takes almost no external input, which is
itself a control. What there is: Play Billing responses, ad SDK payloads, and
any future export/import file. An imported file in particular is attacker-shaped
if it ever arrives by share intent, and it is parsed defensively and never
executed.
itself a control. What there is: Play Billing responses, ad SDK payloads, and,
since #58, an archive the user picks from her own storage.
The archive is the largest untrusted input this app takes, and it is handled as
data throughout:
- **It is identified by its contents, not by its name or its MIME type.** The
picker filters on `application/json` and `application/octet-stream` because a
provider that dropped the extension would otherwise grey out the user's own
file; what decides whether a file is read is its `format` magic string and its
`formatVersion`, checked before anything else in it is looked at.
- **It is refused whole or accepted whole.** `ExportReader` is strict — duplicate
keys, trailing content, non-integer numbers and unsupported escapes are all
refusals — and `DataImporter` parses the entire file into memory before the
database is touched once, in one transaction. There is no state in which half
a stranger's file has been written into a cycle history.
- **It is capped before it is read.** A `content://` URI can point at anything
the user can reach, so anything past 8 MB is refused as unreadable rather than
read into memory. A decade of daily entries is under one megabyte.
- **It cannot touch the lock.** The archive carries a `biometricUnlock` flag and
the importer does not apply it. There is no PIN in the file and there cannot
be, so honouring the flag would at best do nothing and at worst turn on a
fingerprint shortcut for a lock set on this phone, from a file that could have
come from anywhere. `DataImporterTest` holds that as a named test.
- **Nothing about its contents is logged**, including its failures — a parse
error that named the line it choked on would quote a cycle date into a crash
report. Failures are reported as one of four states the screen can render.
## Deliberately out of scope

View File

@ -2,8 +2,16 @@ package dev.privacyllc.period.domain.cycle
import java.time.LocalDate
/** How a period record came to exist. Kept on the record: edits are recorded, never silent. */
enum class PeriodRecordSource { MANUAL, NOTIFICATION_CONFIRMATION, HISTORICAL_ENTRY, EDITED }
/**
* How a record came to exist. Kept on the record: edits are recorded, never silent.
*
* `IMPORTED` is the honest answer for a record read back from an export: it was
* real on another phone, but this app did not watch it happen and cannot vouch
* for how it was entered there. It is also what an unrecognised source becomes
* a file from a newer version naming something this build has never heard of is
* still a real period, and guessing at MANUAL would be a claim.
*/
enum class PeriodRecordSource { MANUAL, NOTIFICATION_CONFIRMATION, HISTORICAL_ENTRY, EDITED, IMPORTED }
/**
* A period the user confirmed.