diff --git a/app/src/main/kotlin/dev/privacyllc/period/MainActivity.kt b/app/src/main/kotlin/dev/privacyllc/period/MainActivity.kt index 3c152ff..05c14e1 100644 --- a/app/src/main/kotlin/dev/privacyllc/period/MainActivity.kt +++ b/app/src/main/kotlin/dev/privacyllc/period/MainActivity.kt @@ -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 { diff --git a/app/src/main/kotlin/dev/privacyllc/period/feature/export/DataImporter.kt b/app/src/main/kotlin/dev/privacyllc/period/feature/export/DataImporter.kt new file mode 100644 index 0000000..5a8d871 --- /dev/null +++ b/app/src/main/kotlin/dev/privacyllc/period/feature/export/DataImporter.kt @@ -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 + } +} diff --git a/app/src/main/kotlin/dev/privacyllc/period/feature/export/ExportMapping.kt b/app/src/main/kotlin/dev/privacyllc/period/feature/export/ExportMapping.kt index 42db5d6..4ca9e3b 100644 --- a/app/src/main/kotlin/dev/privacyllc/period/feature/export/ExportMapping.kt +++ b/app/src/main/kotlin/dev/privacyllc/period/feature/export/ExportMapping.kt @@ -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) +} diff --git a/app/src/main/kotlin/dev/privacyllc/period/feature/export/ImportController.kt b/app/src/main/kotlin/dev/privacyllc/period/feature/export/ImportController.kt new file mode 100644 index 0000000..3228ffa --- /dev/null +++ b/app/src/main/kotlin/dev/privacyllc/period/feature/export/ImportController.kt @@ -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.Idle) + val state: StateFlow = _state.asStateFlow() + + /** True when a picker should be opened. */ + private val _launchRequest = MutableStateFlow(false) + val launchRequest: StateFlow = _launchRequest.asStateFlow() + + private val _pending = MutableStateFlow(null) + val pending: StateFlow = _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 + } +} diff --git a/app/src/main/kotlin/dev/privacyllc/period/feature/export/ImportCopy.kt b/app/src/main/kotlin/dev/privacyllc/period/feature/export/ImportCopy.kt new file mode 100644 index 0000000..238ea79 --- /dev/null +++ b/app/src/main/kotlin/dev/privacyllc/period/feature/export/ImportCopy.kt @@ -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}" +} diff --git a/app/src/main/kotlin/dev/privacyllc/period/feature/export/ImportHost.kt b/app/src/main/kotlin/dev/privacyllc/period/feature/export/ImportHost.kt new file mode 100644 index 0000000..dc16ff5 --- /dev/null +++ b/app/src/main/kotlin/dev/privacyllc/period/feature/export/ImportHost.kt @@ -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) } + } + } +} diff --git a/app/src/main/kotlin/dev/privacyllc/period/feature/export/ImportScreen.kt b/app/src/main/kotlin/dev/privacyllc/period/feature/export/ImportScreen.kt new file mode 100644 index 0000000..ed6dec4 --- /dev/null +++ b/app/src/main/kotlin/dev/privacyllc/period/feature/export/ImportScreen.kt @@ -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 = {}) } +} diff --git a/app/src/main/kotlin/dev/privacyllc/period/feature/export/ImportViewModel.kt b/app/src/main/kotlin/dev/privacyllc/period/feature/export/ImportViewModel.kt new file mode 100644 index 0000000..b2f1747 --- /dev/null +++ b/app/src/main/kotlin/dev/privacyllc/period/feature/export/ImportViewModel.kt @@ -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 = 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() +} diff --git a/app/src/main/kotlin/dev/privacyllc/period/feature/settings/SettingsScreen.kt b/app/src/main/kotlin/dev/privacyllc/period/feature/settings/SettingsScreen.kt index 31ec030..aef570b 100644 --- a/app/src/main/kotlin/dev/privacyllc/period/feature/settings/SettingsScreen.kt +++ b/app/src/main/kotlin/dev/privacyllc/period/feature/settings/SettingsScreen.kt @@ -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, + ) } diff --git a/app/src/main/kotlin/dev/privacyllc/period/navigation/PeriodApp.kt b/app/src/main/kotlin/dev/privacyllc/period/navigation/PeriodApp.kt index 3fe90e4..725c918 100644 --- a/app/src/main/kotlin/dev/privacyllc/period/navigation/PeriodApp.kt +++ b/app/src/main/kotlin/dev/privacyllc/period/navigation/PeriodApp.kt @@ -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 diff --git a/app/src/test/kotlin/dev/privacyllc/period/feature/export/DataImporterTest.kt b/app/src/test/kotlin/dev/privacyllc/period/feature/export/DataImporterTest.kt new file mode 100644 index 0000000..ced1ee3 --- /dev/null +++ b/app/src/test/kotlin/dev/privacyllc/period/feature/export/DataImporterTest.kt @@ -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() + 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 = 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 }, + ) + } +} diff --git a/app/src/test/kotlin/dev/privacyllc/period/feature/export/ImportControllerTest.kt b/app/src/test/kotlin/dev/privacyllc/period/feature/export/ImportControllerTest.kt new file mode 100644 index 0000000..1e3e617 --- /dev/null +++ b/app/src/test/kotlin/dev/privacyllc/period/feature/export/ImportControllerTest.kt @@ -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) + } +} diff --git a/app/src/test/kotlin/dev/privacyllc/period/feature/export/ImportCopyTest.kt b/app/src/test/kotlin/dev/privacyllc/period/feature/export/ImportCopyTest.kt new file mode 100644 index 0000000..18523fd --- /dev/null +++ b/app/src/test/kotlin/dev/privacyllc/period/feature/export/ImportCopyTest.kt @@ -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")) + } + } +} diff --git a/app/src/test/kotlin/dev/privacyllc/period/feature/export/ImportScreenTest.kt b/app/src/test/kotlin/dev/privacyllc/period/feature/export/ImportScreenTest.kt new file mode 100644 index 0000000..a27ec04 --- /dev/null +++ b/app/src/test/kotlin/dev/privacyllc/period/feature/export/ImportScreenTest.kt @@ -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() + + private val chosen = mutableListOf() + + /** + * `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(), 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(), chosen) + // And the screen is still usable: cancelling a dialog is not an exit. + rule.onNodeWithText(ImportCopy.ADD_ACTION).performScrollTo().assertIsDisplayed() + } +} diff --git a/app/src/test/kotlin/dev/privacyllc/period/feature/lock/LockSettingsViewModelTest.kt b/app/src/test/kotlin/dev/privacyllc/period/feature/lock/LockSettingsViewModelTest.kt index 64b0365..0aa7f31 100644 --- a/app/src/test/kotlin/dev/privacyllc/period/feature/lock/LockSettingsViewModelTest.kt +++ b/app/src/test/kotlin/dev/privacyllc/period/feature/lock/LockSettingsViewModelTest.kt @@ -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}, " + diff --git a/core/data/src/main/kotlin/dev/privacyllc/period/core/data/CycleRepository.kt b/core/data/src/main/kotlin/dev/privacyllc/period/core/data/CycleRepository.kt index 718343f..2a3875d 100644 --- a/core/data/src/main/kotlin/dev/privacyllc/period/core/data/CycleRepository.kt +++ b/core/data/src/main/kotlin/dev/privacyllc/period/core/data/CycleRepository.kt @@ -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, + spotting: List, + 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 // ----------------------------------------------------------------------- diff --git a/core/data/src/main/kotlin/dev/privacyllc/period/core/data/ImportMode.kt b/core/data/src/main/kotlin/dev/privacyllc/period/core/data/ImportMode.kt new file mode 100644 index 0000000..18141cd --- /dev/null +++ b/core/data/src/main/kotlin/dev/privacyllc/period/core/data/ImportMode.kt @@ -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 +} diff --git a/core/data/src/test/kotlin/dev/privacyllc/period/core/data/CycleRepositoryImportTest.kt b/core/data/src/test/kotlin/dev/privacyllc/period/core/data/CycleRepositoryImportTest.kt new file mode 100644 index 0000000..3a29e58 --- /dev/null +++ b/core/data/src/test/kotlin/dev/privacyllc/period/core/data/CycleRepositoryImportTest.kt @@ -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) + } +} diff --git a/core/database/src/main/kotlin/dev/privacyllc/period/core/database/PeriodDatabase.kt b/core/database/src/main/kotlin/dev/privacyllc/period/core/database/PeriodDatabase.kt index 5c2565b..f33a572 100644 --- a/core/database/src/main/kotlin/dev/privacyllc/period/core/database/PeriodDatabase.kt +++ b/core/database/src/main/kotlin/dev/privacyllc/period/core/database/PeriodDatabase.kt @@ -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 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. * diff --git a/core/database/src/main/kotlin/dev/privacyllc/period/core/database/dao/Daos.kt b/core/database/src/main/kotlin/dev/privacyllc/period/core/database/dao/Daos.kt index a012cdc..8391423 100644 --- a/core/database/src/main/kotlin/dev/privacyllc/period/core/database/dao/Daos.kt +++ b/core/database/src/main/kotlin/dev/privacyllc/period/core/database/dao/Daos.kt @@ -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 + + /** `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 diff --git a/core/export/src/main/kotlin/dev/privacyllc/period/core/export/ExportReader.kt b/core/export/src/main/kotlin/dev/privacyllc/period/core/export/ExportReader.kt new file mode 100644 index 0000000..3674e24 --- /dev/null +++ b/core/export/src/main/kotlin/dev/privacyllc/period/core/export/ExportReader.kt @@ -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, + val spotting: List, + 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 { + 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()) diff --git a/core/export/src/main/kotlin/dev/privacyllc/period/core/export/Json.kt b/core/export/src/main/kotlin/dev/privacyllc/period/core/export/Json.kt new file mode 100644 index 0000000..0d9196d --- /dev/null +++ b/core/export/src/main/kotlin/dev/privacyllc/period/core/export/Json.kt @@ -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) : JsonValue + data class Arr(val items: List) : 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() + 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() + 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") diff --git a/core/export/src/test/kotlin/dev/privacyllc/period/core/export/ExportRoundTripTest.kt b/core/export/src/test/kotlin/dev/privacyllc/period/core/export/ExportRoundTripTest.kt new file mode 100644 index 0000000..feaabee --- /dev/null +++ b/core/export/src/test/kotlin/dev/privacyllc/period/core/export/ExportRoundTripTest.kt @@ -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) + } +} diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 3f48d9a..41f89e5 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -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 | diff --git a/docs/design/README.md b/docs/design/README.md index 432a81a..3feaf4e 100644 --- a/docs/design/README.md +++ b/docs/design/README.md @@ -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: diff --git a/docs/security/SECURITY.md b/docs/security/SECURITY.md index f7e631d..ba083a0 100644 --- a/docs/security/SECURITY.md +++ b/docs/security/SECURITY.md @@ -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 diff --git a/domain/cycle/src/main/kotlin/dev/privacyllc/period/domain/cycle/Cycle.kt b/domain/cycle/src/main/kotlin/dev/privacyllc/period/domain/cycle/Cycle.kt index 8e49d26..b90d402 100644 --- a/domain/cycle/src/main/kotlin/dev/privacyllc/period/domain/cycle/Cycle.kt +++ b/domain/cycle/src/main/kotlin/dev/privacyllc/period/domain/cycle/Cycle.kt @@ -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.