From ed0287bcdd382838276d2c2e3ca9bc8a57dd17c5 Mon Sep 17 00:00:00 2001 From: pasichdev Date: Mon, 5 Oct 2026 00:01:24 +0300 Subject: [PATCH 01/19] feat(tasks): break a task into sub-tasks A task can now hold an ordered checklist of sub-tasks, one level deep. They are added, edited, reordered and removed in the task sheet, and the task tile shows progress (2/5). Completing a task never ticks its sub-tasks; ticking the last open one offers "Complete task" in a snackbar instead of completing it silently. Deleting a task removes its sub-tasks with it, and Undo brings them back. Sub-tasks live in a new subtasks table (FK to tasks with ON DELETE CASCADE, unique uid kept by the same triggers as the other tables). The database goes from version 3 to 4 through an explicit migration that matches the exported schema. Backups move to schema 3: each task carries its sub-tasks. Schema 1 and 2 files still restore (with no sub-tasks); MERGE adds the sub-tasks an existing task is missing, by uid. Because decoding is strict, an older Encly reports a schema-3 backup as "update the app", and installing an older build over database version 4 is not possible. Both are noted in the CHANGELOG. Refs: #55 --- CHANGELOG.md | 19 ++ SECURITY.md | 5 +- .../4.json | 307 ++++++++++++++++++ .../data/database/AppDatabaseSchemaTest.kt | 58 ++++ .../pasich/encly/core/backup/BackupPayload.kt | 36 +- .../encly/data/backup/BackupImporter.kt | 73 ++++- .../encly/data/backup/RoomVaultDataStore.kt | 12 +- .../encly/data/backup/VaultDataStore.kt | 12 +- .../pasich/encly/data/database/AppDatabase.kt | 5 +- .../pasich/encly/data/database/VaultSchema.kt | 47 ++- .../encly/data/database/dao/BackupDao.kt | 11 + .../encly/data/database/dao/TasksDao.kt | 43 +++ .../com/pasich/encly/data/model/Subtask.kt | 38 +++ .../data/repository/TasksRepositoryImpl.kt | 18 + .../domain/repository/TasksRepository.kt | 15 + .../presentation/components/tiles/TaskItem.kt | 24 +- .../dialogs/tasks/AddTaskDialog.kt | 44 ++- .../dialogs/tasks/SubtaskEditor.kt | 267 +++++++++++++++ .../encly/presentation/screen/TasksScreen.kt | 46 ++- .../presentation/viewmodel/SubtaskDrafts.kt | 57 ++++ .../presentation/viewmodel/TasksViewModel.kt | 145 +++++++-- app/src/main/res/values-de/strings.xml | 6 + app/src/main/res/values-es/strings.xml | 6 + app/src/main/res/values-fr/strings.xml | 6 + app/src/main/res/values-it/strings.xml | 6 + app/src/main/res/values-nl/strings.xml | 6 + app/src/main/res/values-pl/strings.xml | 6 + app/src/main/res/values-pt/strings.xml | 6 + app/src/main/res/values-uk/strings.xml | 6 + app/src/main/res/values/strings.xml | 7 + .../core/backup/BackupPayloadCodecTest.kt | 83 ++++- .../encly/data/backup/BackupImporterTest.kt | 79 ++++- .../encly/data/database/SubtasksDaoTest.kt | 134 ++++++++ .../viewmodel/SubtaskDraftsTest.kt | 72 ++++ .../viewmodel/TasksViewModelDraftTest.kt | 19 ++ .../viewmodel/TasksViewModelSubtasksTest.kt | 194 +++++++++++ .../encly/testutil/InMemoryVaultDataStore.kt | 13 + .../encly/testutil/TestTasksRepository.kt | 40 ++- .../java/com/pasich/encly/ui/screens/Fakes.kt | 14 + docs/architecture.md | 16 +- 40 files changed, 1900 insertions(+), 101 deletions(-) create mode 100644 app/schemas/com.pasich.encly.data.database.AppDatabase/4.json create mode 100644 app/src/main/java/com/pasich/encly/data/model/Subtask.kt create mode 100644 app/src/main/java/com/pasich/encly/presentation/dialogs/tasks/SubtaskEditor.kt create mode 100644 app/src/main/java/com/pasich/encly/presentation/viewmodel/SubtaskDrafts.kt create mode 100644 app/src/test/java/com/pasich/encly/data/database/SubtasksDaoTest.kt create mode 100644 app/src/test/java/com/pasich/encly/presentation/viewmodel/SubtaskDraftsTest.kt create mode 100644 app/src/test/java/com/pasich/encly/presentation/viewmodel/TasksViewModelSubtasksTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ad87d0..b0f46c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,25 @@ IzzyOnDroid) and used as the GitHub Release notes. ## [Unreleased] +### Added + +- Sub-tasks: a task can have a checklist of sub-tasks (one level). Add, edit, tick, reorder + (drag or the accessibility actions) and delete them in the task sheet; the task tile shows the + progress, e.g. `2/5`. Completing a task never ticks its sub-tasks, and ticking the last open + sub-task does not complete the task on its own: a snackbar offers "Complete task". Deleting a + task deletes its sub-tasks, and Undo brings them back. +- Encrypted backups carry sub-tasks (backup schema 3). Older backups (schema 1 and 2) still + restore, with no sub-tasks. Merge import adds the sub-tasks a task already on the device is + missing. + +### ⚠️ Compatibility + +- The database moves to version 4 (new `subtasks` table, migrated in place). Installing an older + Encly over this version is not supported: Room refuses to open a newer database, so a + downgrade fails to open the vault. Export a backup first if you might go back. +- A backup made with this version is schema 3. Older Encly versions refuse it as "made by a + newer version, update the app" instead of restoring it without sub-tasks. + ## [2.0.1] - 2026-09-25 versionCode 20001. diff --git a/SECURITY.md b/SECURITY.md index 350be71..2fce97c 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -159,8 +159,9 @@ fileKey = HKDF-SHA256(ikm = backupRoot, salt = <32 random bytes per file>, in payload (schema version, unique uids, links that resolve, known priorities) — all **before touching the database**. It is then applied in one SQLite transaction: any failure rolls the whole import back. -- Merge adds only records whose uid is not in the vault yet (local versions win); replace deletes - every note, tag and task first and needs an explicit confirmation. +- Merge adds only records (sub-tasks included) whose uid is not in the vault yet (local versions + win); replace deletes every note, tag, task and sub-task first and needs an explicit + confirmation. - Wrong words and any modification of the header or ciphertext fail AES-GCM authentication and are reported as one error ("these words don't open this backup, or the file was modified"), with nothing written. diff --git a/app/schemas/com.pasich.encly.data.database.AppDatabase/4.json b/app/schemas/com.pasich.encly.data.database.AppDatabase/4.json new file mode 100644 index 0000000..7af5b95 --- /dev/null +++ b/app/schemas/com.pasich.encly.data.database.AppDatabase/4.json @@ -0,0 +1,307 @@ +{ + "formatVersion": 1, + "database": { + "version": 4, + "identityHash": "7f7ad14ca5610ef5a20272f6619153a8", + "entities": [ + { + "tableName": "notes", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `title` TEXT NOT NULL, `value` TEXT NOT NULL, `description` TEXT NOT NULL, `date` INTEGER NOT NULL, `dateCreate` INTEGER NOT NULL, `tagId` INTEGER, `isTrash` INTEGER NOT NULL, `uid` TEXT NOT NULL DEFAULT '')", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "dateCreate", + "columnName": "dateCreate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tagId", + "columnName": "tagId", + "affinity": "INTEGER" + }, + { + "fieldPath": "isTrash", + "columnName": "isTrash", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_notes_uid", + "unique": true, + "columnNames": [ + "uid" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_notes_uid` ON `${TABLE_NAME}` (`uid`)" + } + ] + }, + { + "tableName": "tags", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `visibility` INTEGER NOT NULL, `position` INTEGER NOT NULL, `uid` TEXT NOT NULL DEFAULT '')", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "nameTag", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isVisible", + "columnName": "visibility", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_tags_uid", + "unique": true, + "columnNames": [ + "uid" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_tags_uid` ON `${TABLE_NAME}` (`uid`)" + } + ] + }, + { + "tableName": "tasks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `title` TEXT NOT NULL, `description` TEXT, `isCompleted` INTEGER NOT NULL, `createdDate` INTEGER NOT NULL, `completedDate` INTEGER, `priority` INTEGER NOT NULL, `categoryId` INTEGER, `position` INTEGER NOT NULL, `uid` TEXT NOT NULL DEFAULT '')", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT" + }, + { + "fieldPath": "isCompleted", + "columnName": "isCompleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdDate", + "columnName": "createdDate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "completedDate", + "columnName": "completedDate", + "affinity": "INTEGER" + }, + { + "fieldPath": "priority", + "columnName": "priority", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "categoryId", + "columnName": "categoryId", + "affinity": "INTEGER" + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_tasks_uid", + "unique": true, + "columnNames": [ + "uid" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_tasks_uid` ON `${TABLE_NAME}` (`uid`)" + } + ] + }, + { + "tableName": "subtasks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `taskId` INTEGER NOT NULL, `title` TEXT NOT NULL, `isCompleted` INTEGER NOT NULL, `position` INTEGER NOT NULL, `uid` TEXT NOT NULL DEFAULT '', FOREIGN KEY(`taskId`) REFERENCES `tasks`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "taskId", + "columnName": "taskId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isCompleted", + "columnName": "isCompleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_subtasks_taskId", + "unique": false, + "columnNames": [ + "taskId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_subtasks_taskId` ON `${TABLE_NAME}` (`taskId`)" + }, + { + "name": "index_subtasks_uid", + "unique": true, + "columnNames": [ + "uid" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_subtasks_uid` ON `${TABLE_NAME}` (`uid`)" + } + ], + "foreignKeys": [ + { + "table": "tasks", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "taskId" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '7f7ad14ca5610ef5a20272f6619153a8')" + ] + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/pasich/encly/data/database/AppDatabaseSchemaTest.kt b/app/src/androidTest/java/com/pasich/encly/data/database/AppDatabaseSchemaTest.kt index 99200ad..f289d9e 100644 --- a/app/src/androidTest/java/com/pasich/encly/data/database/AppDatabaseSchemaTest.kt +++ b/app/src/androidTest/java/com/pasich/encly/data/database/AppDatabaseSchemaTest.kt @@ -70,6 +70,64 @@ class AppDatabaseSchemaTest { } } + @Test + fun migrate3To4AddsSubtasksWithTriggersAndCascade() { + helper.createDatabase(TEST_DB, 3).use { db -> + db.execSQL( + "INSERT INTO tasks (id, title, isCompleted, createdDate, priority, position, uid) " + + "VALUES (7, 'Buy milk', 0, 100, 2, 3, 'task-uid')", + ) + } + + helper.runMigrationsAndValidate(TEST_DB, 4, true, VaultSchema.MIGRATION_3_4).use { db -> + db.query("SELECT id, title, uid FROM tasks").use { c -> + assertTrue(c.moveToFirst()) + assertEquals(7L, c.getLong(0)) + assertEquals("Buy milk", c.getString(1)) + assertEquals("task-uid", c.getString(2)) + } + db.query("SELECT COUNT(*) FROM subtasks").use { c -> + assertTrue(c.moveToFirst()) + assertEquals(0, c.getInt(0)) + } + + // A blank uid is filled in, and a later blank update keeps it. + db.execSQL("INSERT INTO subtasks (taskId, title, isCompleted, position) VALUES (7, 'Oat', 0, 0)") + val uid = db.query("SELECT uid FROM subtasks").use { c -> + assertTrue(c.moveToFirst()) + c.getString(0) + } + assertEquals(32, uid.length) + db.execSQL("UPDATE subtasks SET uid = '' WHERE taskId = 7") + db.query("SELECT uid FROM subtasks").use { c -> + assertTrue(c.moveToFirst()) + assertEquals(uid, c.getString(0)) + } + + // Deleting the task deletes its sub-tasks (Room turns foreign keys on when it opens the vault). + db.execSQL("PRAGMA foreign_keys = ON") + db.execSQL("DELETE FROM tasks WHERE id = 7") + db.query("SELECT COUNT(*) FROM subtasks").use { c -> + assertTrue(c.moveToFirst()) + assertEquals(0, c.getInt(0)) + } + } + } + + @Test + fun migrate1ToCurrentRunsTheWholeChain() { + helper.createDatabase(TEST_DB, 1).close() + + helper.runMigrationsAndValidate( + TEST_DB, + DB_VERSION, + true, + VaultSchema.MIGRATION_1_2, + VaultSchema.MIGRATION_2_3, + VaultSchema.MIGRATION_3_4, + ).close() + } + private companion object { const val TEST_DB = "schema-test.db" } diff --git a/app/src/main/java/com/pasich/encly/core/backup/BackupPayload.kt b/app/src/main/java/com/pasich/encly/core/backup/BackupPayload.kt index e462691..770779e 100644 --- a/app/src/main/java/com/pasich/encly/core/backup/BackupPayload.kt +++ b/app/src/main/java/com/pasich/encly/core/backup/BackupPayload.kt @@ -30,6 +30,7 @@ import java.io.ByteArrayOutputStream * Schema history: * - 1: first format; tasks carried a `reminderDate`. * - 2: task reminders removed from the app, so tasks no longer carry `reminderDate`. + * - 3: each task carries its `subtasks` (a list, empty for a task without any). */ @Serializable data class BackupPayload( @@ -40,10 +41,13 @@ data class BackupPayload( val tasks: List, ) { companion object { - const val SCHEMA_VERSION = 2 + const val SCHEMA_VERSION = 3 /** Tasks carried a `reminderDate`, dropped on import. */ const val SCHEMA_V1 = 1 + + /** Tasks had no sub-tasks; they are imported with none. */ + const val SCHEMA_V2 = 2 } } @@ -74,13 +78,20 @@ data class BackupTask( val priority: Int, val categoryTagUid: String?, val position: Int, + val subtasks: List, ) +/** A task's checklist item. Its task is the [BackupTask] that holds it; [uid]s are vault-wide unique. */ +@Serializable +data class BackupSubtask(val uid: String, val title: String, val isCompleted: Boolean, val position: Int) + /** UTF-8 JSON encoding of [BackupPayload] with strict validation on the way in. */ @OptIn(ExperimentalSerializationApi::class) object BackupPayloadCodec { const val MAX_UID_LENGTH = 64 private const val REMINDER_DATE_KEY = "reminderDate" + private const val TASKS_KEY = "tasks" + private const val SUBTASKS_KEY = "subtasks" private val PRIORITIES = 0..2 private val json = Json { @@ -116,9 +127,9 @@ object BackupPayloadCodec { val schema = invalidPayloadOn { decodeStream(schemaJson, SchemaProbe.serializer(), plaintext).schema } if (schema > BackupPayload.SCHEMA_VERSION) throw BackupException(BackupError.UNSUPPORTED_VERSION) val payload = invalidPayloadOn { - if (schema == BackupPayload.SCHEMA_V1) { + if (schema == BackupPayload.SCHEMA_V1 || schema == BackupPayload.SCHEMA_V2) { val tree = decodeStream(json, JsonElement.serializer(), plaintext) - json.decodeFromJsonElement(BackupPayload.serializer(), upgradeFromV1(tree.jsonObject)) + json.decodeFromJsonElement(BackupPayload.serializer(), upgrade(tree.jsonObject, schema)) } else { decodeStream(json, BackupPayload.serializer(), plaintext) } @@ -127,12 +138,20 @@ object BackupPayloadCodec { return payload } - /** Schema 1 -> 2: drop each task's `reminderDate`; everything else is unchanged. */ - private fun upgradeFromV1(v1: JsonObject): JsonObject { - val tasks = v1["tasks"]?.jsonArray?.map { JsonObject(it.jsonObject - REMINDER_DATE_KEY) } + /** + * Schema 1 or 2 -> current. 1 -> 2 drops each task's `reminderDate`; 2 -> 3 gives each task + * an empty `subtasks` list. Everything else is unchanged. A `subtasks` key in an older file + * is not something that schema had, so it fails like any other unknown key. + */ + private fun upgrade(old: JsonObject, schema: Int): JsonObject { + val tasks = old[TASKS_KEY]?.jsonArray?.map { element -> + val task = element.jsonObject.let { if (schema == BackupPayload.SCHEMA_V1) it - REMINDER_DATE_KEY else it } + require(SUBTASKS_KEY !in task) { "Unexpected sub-tasks in schema $schema" } + JsonObject(task + (SUBTASKS_KEY to JsonArray(emptyList()))) + } return JsonObject( - v1 + ("schema" to JsonPrimitive(BackupPayload.SCHEMA_VERSION)) + - (tasks?.let { mapOf("tasks" to JsonArray(it)) }.orEmpty()), + old + ("schema" to JsonPrimitive(BackupPayload.SCHEMA_VERSION)) + + (tasks?.let { mapOf(TASKS_KEY to JsonArray(it)) }.orEmpty()), ) } @@ -153,6 +172,7 @@ object BackupPayloadCodec { uniqueValidUids(payload.tags.map { it.uid }) && uniqueValidUids(payload.notes.map { it.uid }) && uniqueValidUids(payload.tasks.map { it.uid }) && + uniqueValidUids(payload.tasks.flatMap { task -> task.subtasks.map { it.uid } }) && payload.notes.all { it.tagUid == null || it.tagUid in tagUids } && payload.tasks.all { it.priority in PRIORITIES && (it.categoryTagUid == null || it.categoryTagUid in tagUids) diff --git a/app/src/main/java/com/pasich/encly/data/backup/BackupImporter.kt b/app/src/main/java/com/pasich/encly/data/backup/BackupImporter.kt index 9ab18e2..5ab4de7 100644 --- a/app/src/main/java/com/pasich/encly/data/backup/BackupImporter.kt +++ b/app/src/main/java/com/pasich/encly/data/backup/BackupImporter.kt @@ -3,26 +3,39 @@ package com.pasich.encly.data.backup import com.pasich.encly.core.backup.BackupNote import com.pasich.encly.core.backup.BackupPayload import com.pasich.encly.core.backup.BackupPayloadCodec +import com.pasich.encly.core.backup.BackupSubtask import com.pasich.encly.core.backup.BackupTag import com.pasich.encly.core.backup.BackupTask import com.pasich.encly.data.model.Note +import com.pasich.encly.data.model.Subtask import com.pasich.encly.data.model.Tag import com.pasich.encly.data.model.Task enum class ImportMode { - /** Keep the vault; add backup records whose uid is not present yet, skip the rest. */ + /** + * Keep the vault; add backup records whose uid is not present yet, skip the rest. A task + * that is already here still gets the backup's sub-tasks it is missing. + */ MERGE, - /** Delete every note, tag and task first, then add the whole backup. */ + /** Delete every note, tag, task and sub-task first, then add the whole backup. */ REPLACE, } -data class ImportSummary(val notesAdded: Int, val tagsAdded: Int, val tasksAdded: Int, val skipped: Int) +/** [skipped] counts notes, tags and tasks; sub-tasks are reported on their own. */ +data class ImportSummary( + val notesAdded: Int, + val tagsAdded: Int, + val tasksAdded: Int, + val skipped: Int, + val subtasksAdded: Int = 0, +) /** Vault rows <-> [BackupPayload]. Links travel as uids, never as local autoincrement ids. */ object BackupMapper { fun toPayload(snapshot: VaultSnapshot, exportedAt: Long): BackupPayload { val tagUidById = snapshot.tags.associate { it.id to it.uid } + val subtasksByTask = snapshot.subtasks.groupBy { it.taskId } return BackupPayload( exportedAt = exportedAt, tags = snapshot.tags.map { BackupTag(it.uid, it.nameTag, it.isVisible, it.position) }, @@ -49,6 +62,9 @@ object BackupMapper { priority = task.priority, categoryTagUid = task.categoryId?.let(tagUidById::get), position = task.position, + subtasks = subtasksByTask[task.id].orEmpty() + .sortedWith(compareBy({ it.position }, { it.id })) + .map { BackupSubtask(it.uid, it.title, it.isCompleted, it.position) }, ) }, ).also(BackupPayloadCodec::validate) @@ -66,7 +82,7 @@ object BackupImporter { return store.inTransaction { val existing = if (mode == ImportMode.REPLACE) { store.deleteAll() - VaultSnapshot(emptyList(), emptyList(), emptyList()) + VaultSnapshot(emptyList(), emptyList(), emptyList(), emptyList()) } else { store.snapshot() } @@ -77,9 +93,15 @@ object BackupImporter { val newNotes = payload.notes.filter { it.uid !in noteUids } newNotes.forEach { store.insertNote(it.toEntity(tagIds)) } - val taskUids = existing.tasks.mapTo(HashSet()) { it.uid } - val newTasks = payload.tasks.filter { it.uid !in taskUids } - newTasks.forEach { store.insertTask(it.toEntity(tagIds)) } + val taskIds = existing.tasks.associate { it.uid to it.id } + val newTasks = payload.tasks.filter { it.uid !in taskIds } + var subtasksAdded = 0 + newTasks.forEach { task -> + val taskId = store.insertTask(task.toEntity(tagIds)) + task.subtasks.forEach { store.insertSubtask(it.toEntity(taskId, it.position)) } + subtasksAdded += task.subtasks.size + } + subtasksAdded += mergeSubtasks(payload.tasks.filter { it.uid in taskIds }, taskIds, existing, store) val total = payload.tags.size + payload.notes.size + payload.tasks.size val added = tagsAdded + newNotes.size + newTasks.size @@ -88,6 +110,7 @@ object BackupImporter { tagsAdded = tagsAdded, tasksAdded = newTasks.size, skipped = total - added, + subtasksAdded = subtasksAdded, ) } } @@ -118,6 +141,42 @@ object BackupImporter { return added } + /** + * For tasks already in the vault (MERGE only): adds the backup's sub-tasks whose uid is not + * here yet, after the task's own ones and in their backup order. A uid that exists anywhere + * in the vault is skipped, like every other merged record. + */ + private suspend fun mergeSubtasks( + tasks: List, + taskIds: Map, + existing: VaultSnapshot, + store: VaultDataStore, + ): Int { + val subtaskUids = existing.subtasks.mapTo(HashSet()) { it.uid } + val nextPositions = existing.subtasks.groupBy { it.taskId } + .mapValues { (_, subtasks) -> subtasks.maxOf { it.position } + 1 } + var added = 0 + tasks.forEach { task -> + val taskId = taskIds.getValue(task.uid) + var nextPosition = nextPositions[taskId] ?: 0 + task.subtasks.sortedBy { it.position }.forEach { subtask -> + if (subtask.uid !in subtaskUids) { + store.insertSubtask(subtask.toEntity(taskId, nextPosition++)) + added++ + } + } + } + return added + } + + private fun BackupSubtask.toEntity(taskId: Long, position: Int) = Subtask( + taskId = taskId, + title = title, + isCompleted = isCompleted, + position = position, + uid = uid, + ) + private fun BackupNote.toEntity(tagIds: Map) = Note( title = title, value = value, diff --git a/app/src/main/java/com/pasich/encly/data/backup/RoomVaultDataStore.kt b/app/src/main/java/com/pasich/encly/data/backup/RoomVaultDataStore.kt index 59c81db..b127044 100644 --- a/app/src/main/java/com/pasich/encly/data/backup/RoomVaultDataStore.kt +++ b/app/src/main/java/com/pasich/encly/data/backup/RoomVaultDataStore.kt @@ -3,6 +3,7 @@ package com.pasich.encly.data.backup import androidx.room.withTransaction import com.pasich.encly.data.database.DatabaseProvider import com.pasich.encly.data.model.Note +import com.pasich.encly.data.model.Subtask import com.pasich.encly.data.model.Tag import com.pasich.encly.data.model.Task import javax.inject.Inject @@ -13,13 +14,20 @@ class RoomVaultDataStore @Inject constructor(private val databaseProvider: Datab private fun dao() = database().backupDao() override suspend fun snapshot(): VaultSnapshot = database().withTransaction { - VaultSnapshot(notes = dao().allNotes(), tags = dao().allTags(), tasks = dao().allTasks()) + VaultSnapshot( + notes = dao().allNotes(), + tags = dao().allTags(), + tasks = dao().allTasks(), + subtasks = dao().allSubtasks(), + ) } override suspend fun inTransaction(block: suspend () -> R): R = database().withTransaction { block() } override suspend fun deleteAll() { dao().deleteAllNotes() + // The foreign key would cascade them with their tasks; deleted explicitly all the same. + dao().deleteAllSubtasks() dao().deleteAllTasks() dao().deleteAllTags() } @@ -29,4 +37,6 @@ class RoomVaultDataStore @Inject constructor(private val databaseProvider: Datab override suspend fun insertNote(note: Note): Long = dao().insertNote(note) override suspend fun insertTask(task: Task): Long = dao().insertTask(task) + + override suspend fun insertSubtask(subtask: Subtask): Long = dao().insertSubtask(subtask) } diff --git a/app/src/main/java/com/pasich/encly/data/backup/VaultDataStore.kt b/app/src/main/java/com/pasich/encly/data/backup/VaultDataStore.kt index 44c1bab..ced6443 100644 --- a/app/src/main/java/com/pasich/encly/data/backup/VaultDataStore.kt +++ b/app/src/main/java/com/pasich/encly/data/backup/VaultDataStore.kt @@ -1,11 +1,17 @@ package com.pasich.encly.data.backup import com.pasich.encly.data.model.Note +import com.pasich.encly.data.model.Subtask import com.pasich.encly.data.model.Tag import com.pasich.encly.data.model.Task -/** Every note (trash included), tag and task in the vault, as stored. */ -data class VaultSnapshot(val notes: List, val tags: List, val tasks: List) +/** Every note (trash included), tag, task and sub-task in the vault, as stored. */ +data class VaultSnapshot( + val notes: List, + val tags: List, + val tasks: List, + val subtasks: List = emptyList(), +) /** * The narrow storage contract encrypted export/import needs. The Room implementation is @@ -24,4 +30,6 @@ interface VaultDataStore { suspend fun insertNote(note: Note): Long suspend fun insertTask(task: Task): Long + + suspend fun insertSubtask(subtask: Subtask): Long } diff --git a/app/src/main/java/com/pasich/encly/data/database/AppDatabase.kt b/app/src/main/java/com/pasich/encly/data/database/AppDatabase.kt index 1ba52ed..12569c3 100644 --- a/app/src/main/java/com/pasich/encly/data/database/AppDatabase.kt +++ b/app/src/main/java/com/pasich/encly/data/database/AppDatabase.kt @@ -7,14 +7,15 @@ import com.pasich.encly.data.database.dao.NotesDao import com.pasich.encly.data.database.dao.TagsDao import com.pasich.encly.data.database.dao.TasksDao import com.pasich.encly.data.model.Note +import com.pasich.encly.data.model.Subtask import com.pasich.encly.data.model.Tag import com.pasich.encly.data.model.Task /** Room schema version. Every bump needs a migration and an exported schema in app/schemas. */ -const val DB_VERSION = 3 +const val DB_VERSION = 4 @Database( - entities = [Note::class, Tag::class, Task::class], + entities = [Note::class, Tag::class, Task::class, Subtask::class], version = DB_VERSION, exportSchema = true, ) diff --git a/app/src/main/java/com/pasich/encly/data/database/VaultSchema.kt b/app/src/main/java/com/pasich/encly/data/database/VaultSchema.kt index 7e8e5df..c2ebc7c 100644 --- a/app/src/main/java/com/pasich/encly/data/database/VaultSchema.kt +++ b/app/src/main/java/com/pasich/encly/data/database/VaultSchema.kt @@ -9,19 +9,28 @@ import androidx.sqlite.db.SupportSQLiteDatabase /** * Schema pieces Room cannot express in entity annotations. * - * Every note, tag and task carries a stable `uid` (encrypted backups merge by it). Kotlin code - * never assigns one, so two triggers keep the invariant "uid is never blank" in the database - * itself: a blank uid is replaced with 128 random bits on insert, and an update that would - * blank an existing uid (an entity built without it) restores the old value. + * Every note, tag, task and sub-task carries a stable `uid` (encrypted backups merge by it). + * Kotlin code never assigns one, so two triggers keep the invariant "uid is never blank" in the + * database itself: a blank uid is replaced with 128 random bits on insert, and an update that + * would blank an existing uid (an entity built without it) restores the old value. */ object VaultSchema { - private val TABLES = listOf("notes", "tags", "tasks") + /** The tables that got a `uid` in version 2. Fixed: later tables are created with one. */ + private val V2_UID_TABLES = listOf("notes", "tags", "tasks") + + private const val SUBTASKS = "subtasks" + + /** Every table with a `uid` and its triggers, in the current schema. */ + private val TABLES = V2_UID_TABLES + SUBTASKS private const val RANDOM_UID = "lower(hex(randomblob(16)))" /** Database version that dropped `tasks.reminderDate`. */ private const val VERSION_WITHOUT_TASK_REMINDERS = 3 + /** Database version that added the `subtasks` table. */ + private const val VERSION_WITH_SUBTASKS = 4 + private fun triggers(table: String) = listOf( "CREATE TRIGGER IF NOT EXISTS `${table}_uid_on_insert` AFTER INSERT ON `$table` " + "WHEN NEW.uid = '' BEGIN " + @@ -38,12 +47,14 @@ object VaultSchema { /** 1 -> 2: add the stable `uid` to notes, tags and tasks and back-fill existing rows. */ val MIGRATION_1_2 = object : Migration(1, 2) { override fun migrate(db: SupportSQLiteDatabase) { - TABLES.forEach { table -> + V2_UID_TABLES.forEach { table -> db.execSQL("ALTER TABLE `$table` ADD COLUMN `uid` TEXT NOT NULL DEFAULT ''") db.execSQL("UPDATE `$table` SET uid = $RANDOM_UID WHERE uid = ''") db.execSQL("CREATE UNIQUE INDEX IF NOT EXISTS `index_${table}_uid` ON `$table` (`uid`)") } - createTriggers(db) + // Only these tables exist at version 2; later tables get their triggers in their + // own migration. + V2_UID_TABLES.flatMap(::triggers).forEach(db::execSQL) } } @@ -73,6 +84,26 @@ object VaultSchema { } } + /** + * 3 -> 4: add the `subtasks` table (a task's checklist), with its index on `taskId`, the + * unique `uid` index and the uid triggers. Existing tasks simply have no sub-tasks. The + * statements match Room's export of version 4 (app/schemas), which the schema test checks. + */ + val MIGRATION_3_4 = object : Migration(VERSION_WITHOUT_TASK_REMINDERS, VERSION_WITH_SUBTASKS) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + "CREATE TABLE IF NOT EXISTS `$SUBTASKS` (" + + "`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `taskId` INTEGER NOT NULL, " + + "`title` TEXT NOT NULL, `isCompleted` INTEGER NOT NULL, `position` INTEGER NOT NULL, " + + "`uid` TEXT NOT NULL DEFAULT '', " + + "FOREIGN KEY(`taskId`) REFERENCES `tasks`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + ) + db.execSQL("CREATE INDEX IF NOT EXISTS `index_subtasks_taskId` ON `$SUBTASKS` (`taskId`)") + db.execSQL("CREATE UNIQUE INDEX IF NOT EXISTS `index_subtasks_uid` ON `$SUBTASKS` (`uid`)") + triggers(SUBTASKS).forEach(db::execSQL) + } + } + /** * The builder for the vault database: every migration and the trigger callback, and no * destructive fallback. Room's default (`requireMigration`) makes a missing migration @@ -86,7 +117,7 @@ object VaultSchema { * new migration here explicitly. */ fun install(builder: RoomDatabase.Builder): RoomDatabase.Builder = - builder.addMigrations(MIGRATION_1_2, MIGRATION_2_3).addCallback(CALLBACK) + builder.addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4).addCallback(CALLBACK) /** Installs the triggers on a freshly created database. */ val CALLBACK = object : RoomDatabase.Callback() { diff --git a/app/src/main/java/com/pasich/encly/data/database/dao/BackupDao.kt b/app/src/main/java/com/pasich/encly/data/database/dao/BackupDao.kt index 461ae5d..aeb4b03 100644 --- a/app/src/main/java/com/pasich/encly/data/database/dao/BackupDao.kt +++ b/app/src/main/java/com/pasich/encly/data/database/dao/BackupDao.kt @@ -5,6 +5,7 @@ import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import com.pasich.encly.data.model.Note +import com.pasich.encly.data.model.Subtask import com.pasich.encly.data.model.Tag import com.pasich.encly.data.model.Task @@ -12,6 +13,7 @@ import com.pasich.encly.data.model.Task * Whole-vault reads and writes for encrypted export/import. Inserts ABORT on a uid clash so a * bad import fails (and its transaction rolls back) instead of silently replacing a row. */ +@Suppress("TooManyFunctions") // One read, insert and delete per vault table. @Dao interface BackupDao { @Query("SELECT * FROM notes ORDER BY id") @@ -23,6 +25,9 @@ interface BackupDao { @Query("SELECT * FROM tasks ORDER BY id") suspend fun allTasks(): List + @Query("SELECT * FROM subtasks ORDER BY taskId, position, id") + suspend fun allSubtasks(): List + @Insert(onConflict = OnConflictStrategy.ABORT) suspend fun insertNote(note: Note): Long @@ -32,6 +37,9 @@ interface BackupDao { @Insert(onConflict = OnConflictStrategy.ABORT) suspend fun insertTask(task: Task): Long + @Insert(onConflict = OnConflictStrategy.ABORT) + suspend fun insertSubtask(subtask: Subtask): Long + @Query("DELETE FROM notes") suspend fun deleteAllNotes() @@ -40,4 +48,7 @@ interface BackupDao { @Query("DELETE FROM tasks") suspend fun deleteAllTasks() + + @Query("DELETE FROM subtasks") + suspend fun deleteAllSubtasks() } diff --git a/app/src/main/java/com/pasich/encly/data/database/dao/TasksDao.kt b/app/src/main/java/com/pasich/encly/data/database/dao/TasksDao.kt index c20f278..5907afb 100644 --- a/app/src/main/java/com/pasich/encly/data/database/dao/TasksDao.kt +++ b/app/src/main/java/com/pasich/encly/data/database/dao/TasksDao.kt @@ -5,7 +5,10 @@ import androidx.room.Delete import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query +import androidx.room.Transaction import androidx.room.Update +import com.pasich.encly.data.model.Subtask +import com.pasich.encly.data.model.SubtaskProgress import com.pasich.encly.data.model.Task import kotlinx.coroutines.flow.Flow @@ -47,4 +50,44 @@ interface TasksDao { @Query("DELETE FROM tasks WHERE id = :id") suspend fun deleteTaskById(id: Long): Int + + @Query("SELECT * FROM subtasks WHERE taskId = :taskId ORDER BY position, id") + suspend fun getSubtasks(taskId: Long): List + + /** Done and total sub-tasks per task; tasks without sub-tasks are absent. */ + @Query( + "SELECT taskId, SUM(isCompleted) AS done, COUNT(*) AS total FROM subtasks GROUP BY taskId", + ) + fun getSubtaskProgress(): Flow> + + @Insert(onConflict = OnConflictStrategy.ABORT) + suspend fun insertSubtask(subtask: Subtask): Long + + @Update + suspend fun updateSubtask(subtask: Subtask): Int + + @Query("DELETE FROM subtasks WHERE taskId = :taskId AND id NOT IN (:keepIds)") + suspend fun deleteSubtasksExcept(taskId: Long, keepIds: List): Int + + /** + * Makes [subtasks] the task's whole checklist, in this order: rows that are no longer in + * the list are deleted, the others are updated in place (keeping their id and uid), and + * new ones (id 0) are inserted. + */ + @Transaction + suspend fun replaceSubtasks(taskId: Long, subtasks: List) { + deleteSubtasksExcept(taskId, subtasks.map { it.id }.filter { it != 0L }) + subtasks.forEachIndexed { index, subtask -> + val row = subtask.copy(taskId = taskId, position = index) + // A row id that is gone (deleted meanwhile) is inserted again rather than lost. + if (row.id == 0L || updateSubtask(row) == 0) insertSubtask(row) + } + } + + /** Puts a deleted task back together with its sub-tasks (undo). */ + @Transaction + suspend fun restoreTask(task: Task, subtasks: List) { + insertTask(task) + subtasks.forEach { insertSubtask(it.copy(taskId = task.id)) } + } } diff --git a/app/src/main/java/com/pasich/encly/data/model/Subtask.kt b/app/src/main/java/com/pasich/encly/data/model/Subtask.kt new file mode 100644 index 0000000..11bdb1c --- /dev/null +++ b/app/src/main/java/com/pasich/encly/data/model/Subtask.kt @@ -0,0 +1,38 @@ +package com.pasich.encly.data.model + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index +import androidx.room.PrimaryKey + +/** + * One item of a task's checklist. One level only: a sub-task has no sub-tasks of its own. + * Deleting the task deletes its sub-tasks (`ON DELETE CASCADE`). [uid]: stable backup + * identity, see [Note.uid]. + */ +@Entity( + tableName = "subtasks", + foreignKeys = [ + ForeignKey( + entity = Task::class, + parentColumns = ["id"], + childColumns = ["taskId"], + onDelete = ForeignKey.CASCADE, + ), + ], + indices = [Index(value = ["taskId"]), Index(value = ["uid"], unique = true)], +) +data class Subtask( + @PrimaryKey(autoGenerate = true) + val id: Long = 0, + val taskId: Long, + val title: String, + val isCompleted: Boolean = false, + val position: Int = 0, + @ColumnInfo(defaultValue = "''") + val uid: String = "", +) + +/** How many of a task's sub-tasks are done, for the `2/5` on its tile. */ +data class SubtaskProgress(val taskId: Long, val done: Int, val total: Int) diff --git a/app/src/main/java/com/pasich/encly/data/repository/TasksRepositoryImpl.kt b/app/src/main/java/com/pasich/encly/data/repository/TasksRepositoryImpl.kt index 7cbe639..4d332a2 100644 --- a/app/src/main/java/com/pasich/encly/data/repository/TasksRepositoryImpl.kt +++ b/app/src/main/java/com/pasich/encly/data/repository/TasksRepositoryImpl.kt @@ -1,6 +1,8 @@ package com.pasich.encly.data.repository import com.pasich.encly.data.database.DatabaseProvider +import com.pasich.encly.data.model.Subtask +import com.pasich.encly.data.model.SubtaskProgress import com.pasich.encly.data.model.Task import com.pasich.encly.domain.repository.TasksRepository import kotlinx.coroutines.flow.Flow @@ -45,4 +47,20 @@ class TasksRepositoryImpl @Inject constructor(private val databaseProvider: Data override suspend fun deleteAllCompletedTasks(): Result = storageWrite(TAG, "deleteAllCompletedTasks") { dao().deleteAllCompletedTasks() } + + override fun getSubtaskProgress(): Flow> = daoFlow { dao().getSubtaskProgress() } + + override suspend fun getSubtasks(taskId: Long): Result> = storageWrite(TAG, "getSubtasks") { + dao().getSubtasks(taskId) + } + + override suspend fun saveSubtasks(taskId: Long, subtasks: List): Result = + storageWrite(TAG, "saveSubtasks") { + dao().replaceSubtasks(taskId, subtasks) + } + + override suspend fun restoreTask(task: Task, subtasks: List): Result = + storageWrite(TAG, "restoreTask") { + dao().restoreTask(task, subtasks) + } } diff --git a/app/src/main/java/com/pasich/encly/domain/repository/TasksRepository.kt b/app/src/main/java/com/pasich/encly/domain/repository/TasksRepository.kt index 1d8227d..025405c 100644 --- a/app/src/main/java/com/pasich/encly/domain/repository/TasksRepository.kt +++ b/app/src/main/java/com/pasich/encly/domain/repository/TasksRepository.kt @@ -1,9 +1,12 @@ package com.pasich.encly.domain.repository +import com.pasich.encly.data.model.Subtask +import com.pasich.encly.data.model.SubtaskProgress import com.pasich.encly.data.model.Task import kotlinx.coroutines.flow.Flow /** The tasks. Writes follow the [NotesRepository] error contract. */ +@Suppress("TooManyFunctions") // Tasks and their sub-tasks, so a task and its checklist are written together. interface TasksRepository { fun getAllActiveTasks(): Flow> fun getAllCompletedTasks(): Flow> @@ -17,4 +20,16 @@ interface TasksRepository { suspend fun updateTaskStatus(id: Long, isCompleted: Boolean, completedDate: Long?): Result suspend fun deleteTaskById(id: Long): Result suspend fun deleteAllCompletedTasks(): Result + + /** Done/total sub-tasks of every task that has any. */ + fun getSubtaskProgress(): Flow> + + /** The task's sub-tasks in their order. */ + suspend fun getSubtasks(taskId: Long): Result> + + /** Makes [subtasks] the task's whole checklist, in this order (see TasksDao.replaceSubtasks). */ + suspend fun saveSubtasks(taskId: Long, subtasks: List): Result + + /** Undo of a delete: the same task (id, uid, dates) and its sub-tasks, in one transaction. */ + suspend fun restoreTask(task: Task, subtasks: List): Result } diff --git a/app/src/main/java/com/pasich/encly/presentation/components/tiles/TaskItem.kt b/app/src/main/java/com/pasich/encly/presentation/components/tiles/TaskItem.kt index 281e209..dff2511 100644 --- a/app/src/main/java/com/pasich/encly/presentation/components/tiles/TaskItem.kt +++ b/app/src/main/java/com/pasich/encly/presentation/components/tiles/TaskItem.kt @@ -13,6 +13,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.res.stringResource import com.pasich.encly.R +import com.pasich.encly.data.model.SubtaskProgress import com.pasich.encly.data.model.Task import com.pasich.encly.presentation.components.tasks.PriorityValues import com.pasich.encly.presentation.designsystem.EnclyTaskRow @@ -22,6 +23,7 @@ import java.util.Date private const val TASK_REMOVAL_ANIMATION_MS = 400 private const val TASK_TRANSLATION_X = 100f +private const val META_SEPARATOR = " · " private data class TaskCardState(val enabled: Boolean, val isRemoving: Boolean, val animationProgress: Float) @@ -34,6 +36,8 @@ fun TaskItem( modifier: Modifier = Modifier, onTaskClick: ((Task) -> Unit)? = null, enabled: Boolean = true, + /** Shown as `2/5` when the task has sub-tasks. */ + subtasks: SubtaskProgress? = null, ) { var isRemoving by remember(task.id) { mutableStateOf(false) } var shouldComplete by remember(task.id) { mutableStateOf(false) } @@ -61,7 +65,7 @@ fun TaskItem( onComplete = { shouldComplete = true }, onUndo = { onTaskToggle(task.id, false) }, ) - TaskCard(task, state, actions, modifier) + TaskCard(task, subtasks, state, actions, modifier) } @Composable @@ -88,9 +92,21 @@ private fun CompleteTaskAfterAnimation( } @Composable -private fun TaskCard(task: Task, state: TaskCardState, actions: TaskCardActions, modifier: Modifier = Modifier) { +private fun TaskCard( + task: Task, + subtasks: SubtaskProgress?, + state: TaskCardState, + actions: TaskCardActions, + modifier: Modifier = Modifier, +) { val dateFormat = rememberDateTimeFormat() val priority = PriorityValues.getById(task.priority) + val completedAt = task.completedDate?.takeIf { task.isCompleted }?.let { + stringResource(R.string.task_completed_at, dateFormat.format(Date(it))) + } + val progress = subtasks?.takeIf { it.total > 0 }?.let { + stringResource(R.string.subtask_progress, it.done, it.total) + } EnclyTaskRow( title = task.title, checked = task.isCompleted, @@ -101,9 +117,7 @@ private fun TaskCard(task: Task, state: TaskCardState, actions: TaskCardActions, } }, description = task.description, - meta = task.completedDate?.takeIf { task.isCompleted }?.let { - stringResource(R.string.task_completed_at, dateFormat.format(Date(it))) - }, + meta = listOfNotNull(progress, completedAt).joinToString(META_SEPARATOR).ifEmpty { null }, priority = stringResource(priority.label).takeIf { !task.isCompleted }, priorityEmphasis = priority.emphasis, large = true, diff --git a/app/src/main/java/com/pasich/encly/presentation/dialogs/tasks/AddTaskDialog.kt b/app/src/main/java/com/pasich/encly/presentation/dialogs/tasks/AddTaskDialog.kt index 6ff75b4..45a62fa 100644 --- a/app/src/main/java/com/pasich/encly/presentation/dialogs/tasks/AddTaskDialog.kt +++ b/app/src/main/java/com/pasich/encly/presentation/dialogs/tasks/AddTaskDialog.kt @@ -39,6 +39,7 @@ import com.pasich.encly.presentation.designsystem.EnclyChip import com.pasich.encly.presentation.designsystem.EnclyTextButton import com.pasich.encly.presentation.designsystem.EnclyTextField import com.pasich.encly.presentation.designsystem.SectionOverline +import com.pasich.encly.presentation.viewmodel.SubtaskDraft import com.pasich.encly.presentation.viewmodel.TaskDraft import com.pasich.encly.ui.theme.EnclyTheme import kotlinx.coroutines.delay @@ -47,11 +48,13 @@ private const val TASK_TITLE_MAX_LENGTH = 100 private const val TASK_DESCRIPTION_MAX_LENGTH = 150 private const val INITIAL_FOCUS_DELAY_MS = 300L +/** [subtasks] is the checklist to save, null while an edited task's one has not loaded. */ private data class TaskEditorState( val title: String, val description: String, val priority: Int, val editTaskId: Long?, + val subtasks: List?, ) { val isEditMode: Boolean get() = editTaskId != null } @@ -65,13 +68,18 @@ private data class TaskEditorActions( val onDelete: (() -> Unit)? = null, ) +/** Saves the sheet: title, description, priority and the checklist (null: leave it as stored). */ +private fun interface TaskSubmit { + fun submit(title: String, description: String?, priority: Int, subtasks: List?) +} + @OptIn(ExperimentalMaterial3Api::class) @Suppress("LongParameterList") // Compose sheet API: independent state + callbacks from TasksScreen. @Composable fun AddTaskDialog( onDismiss: () -> Unit, sheetState: SheetState, - onAddTask: (title: String, description: String?, priority: Int) -> Unit, + onAddTask: (title: String, description: String?, priority: Int, subtasks: List) -> Unit, editTask: Task? = null, onEditTask: ( ( @@ -79,14 +87,17 @@ fun AddTaskDialog( title: String, description: String?, priority: Int, + subtasks: List?, ) -> Unit )? = null, onBackgroundSave: (TaskDraft) -> Unit = {}, onDeleteTask: ((Task) -> Unit)? = null, + editSubtasks: List? = emptyList(), ) { var title by remember { mutableStateOf(editTask?.title.orEmpty()) } var description by remember { mutableStateOf(editTask?.description.orEmpty()) } var selectedPriority by remember { mutableIntStateOf(editTask?.priority ?: 0) } + val checklist = rememberSubtaskListState(editSubtasks) val titleFocusRequester = remember { FocusRequester() } val state = TaskEditorState( @@ -94,19 +105,28 @@ fun AddTaskDialog( description = description, priority = selectedPriority, editTaskId = editTask?.id, + subtasks = checklist.toSave(), ) + val submit = TaskSubmit { taskTitle, taskDescription, priority, checklist -> + val taskId = editTask?.id + if (taskId != null && onEditTask != null) { + onEditTask(taskId, taskTitle, taskDescription, priority, checklist) + } else { + onAddTask(taskTitle, taskDescription, priority, checklist.orEmpty()) + } + } val actions = TaskEditorActions( onTitleChange = { title = it.take(TASK_TITLE_MAX_LENGTH) }, onDescriptionChange = { description = it.take(TASK_DESCRIPTION_MAX_LENGTH) }, onPrioritySelect = { selectedPriority = it }, - onSubmit = { submitTask(state, onAddTask, onEditTask) }, + onSubmit = { submitTask(state, submit) }, onDelete = editTask?.let { task -> onDeleteTask?.let { delete -> { delete(task) } } }, ) // Backgrounding re-locks the vault and drops this sheet; flush the draft first, like // EditNote does. ON_PAUSE fires well before ProcessLifecycleOwner's delayed ON_STOP. val currentDraft by rememberUpdatedState( - TaskDraft(title, description, selectedPriority), + TaskDraft(title, description, selectedPriority, state.subtasks), ) val currentOnBackgroundSave by rememberUpdatedState(onBackgroundSave) val lifecycleOwner = LocalLifecycleOwner.current @@ -119,7 +139,7 @@ fun AddTaskDialog( } EnclyBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) { - TaskEditorContent(state, titleFocusRequester, actions) + TaskEditorContent(state, titleFocusRequester, actions, checklist) } LaunchedEffect(Unit) { @@ -128,19 +148,9 @@ fun AddTaskDialog( } } -private fun submitTask( - state: TaskEditorState, - onAddTask: (String, String?, Int) -> Unit, - onEditTask: ((Long, String, String?, Int) -> Unit)?, -) { +private fun submitTask(state: TaskEditorState, submit: TaskSubmit) { if (state.title.isBlank()) return - val description = state.description.ifBlank { null } - val taskId = state.editTaskId - if (taskId != null && onEditTask != null) { - onEditTask(taskId, state.title, description, state.priority) - } else { - onAddTask(state.title, description, state.priority) - } + submit.submit(state.title, state.description.ifBlank { null }, state.priority, state.subtasks) } @Composable @@ -148,6 +158,7 @@ private fun TaskEditorContent( state: TaskEditorState, titleFocusRequester: FocusRequester, actions: TaskEditorActions, + checklist: SubtaskListState, ) { val descriptionFocus = remember { FocusRequester() } Column( @@ -176,6 +187,7 @@ private fun TaskEditorContent( fieldModifier = Modifier.focusRequester(descriptionFocus), ) PriorityChips(selected = state.priority, onSelect = actions.onPrioritySelect) + SubtaskEditor(checklist) TaskEditorFooter(state, actions) } } diff --git a/app/src/main/java/com/pasich/encly/presentation/dialogs/tasks/SubtaskEditor.kt b/app/src/main/java/com/pasich/encly/presentation/dialogs/tasks/SubtaskEditor.kt new file mode 100644 index 0000000..393aeec --- /dev/null +++ b/app/src/main/java/com/pasich/encly/presentation/dialogs/tasks/SubtaskEditor.kt @@ -0,0 +1,267 @@ +package com.pasich.encly.presentation.dialogs.tasks + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.CustomAccessibilityAction +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.customActions +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextDecoration +import com.pasich.encly.R +import com.pasich.encly.presentation.designsystem.CheckboxSize +import com.pasich.encly.presentation.designsystem.EnclyCheckbox +import com.pasich.encly.presentation.designsystem.EnclyIcons +import com.pasich.encly.presentation.designsystem.SectionOverline +import com.pasich.encly.presentation.viewmodel.SubtaskDraft +import com.pasich.encly.presentation.viewmodel.SubtaskDrafts +import com.pasich.encly.ui.theme.EnclyTheme +import sh.calvin.reorderable.ReorderableColumn +import sh.calvin.reorderable.ReorderableScope + +internal const val SUBTASK_TITLE_MAX_LENGTH = 100 + +/** + * The editor sheet's checklist: its rows ([subtasks] is null until an edited task's ones have + * loaded) and the text of the "add" field. + */ +@Stable +internal class SubtaskListState(initial: List?) { + var subtasks by mutableStateOf(initial) + private set + var newTitle by mutableStateOf("") + private set + + /** New rows get negative keys; stored rows keep their (positive) ids. */ + private var nextKey = -1L + + /** Takes an edited task's rows once they arrive, after the sheet opened. */ + fun load(loaded: List?) { + if (subtasks == null && loaded != null) subtasks = loaded + } + + /** The checklist to save: a title still in the "add" field counts as a new sub-task. */ + fun toSave(): List? = subtasks?.let { rows -> + if (newTitle.isBlank()) rows else rows + SubtaskDraft(key = nextKey, title = newTitle) + } + + fun setTitle(key: Long, title: String) = update(key) { it.copy(title = title.take(SUBTASK_TITLE_MAX_LENGTH)) } + + fun setChecked(key: Long, checked: Boolean) = update(key) { it.copy(isCompleted = checked) } + + fun remove(key: Long) { + subtasks = subtasks?.filterNot { it.key == key } + } + + fun move(from: Int, to: Int) { + subtasks = subtasks?.let { SubtaskDrafts.move(it, from, to) } + } + + fun changeNewTitle(title: String) { + newTitle = title.take(SUBTASK_TITLE_MAX_LENGTH) + } + + fun add() { + if (newTitle.isBlank()) return + subtasks = subtasks?.plus(SubtaskDraft(key = nextKey--, title = newTitle)) + newTitle = "" + } + + private fun update(key: Long, change: (SubtaskDraft) -> SubtaskDraft) { + subtasks = subtasks?.map { if (it.key == key) change(it) else it } + } +} + +/** A [SubtaskListState] that picks up [initial] when it changes from null (loaded). */ +@Composable +internal fun rememberSubtaskListState(initial: List?): SubtaskListState { + val state = remember { SubtaskListState(initial) } + LaunchedEffect(initial) { state.load(initial) } + return state +} + +/** + * The task's sub-tasks in the editor sheet: one row each (checkbox, title, remove, drag handle + * with "Move up"/"Move down" accessibility actions), and a field that adds a new one. + * One level only: a sub-task has no sub-tasks. Nothing shows until the rows have loaded. + */ +@Composable +internal fun SubtaskEditor(state: SubtaskListState) { + val subtasks = state.subtasks ?: return + val haptics = LocalHapticFeedback.current + Column(verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.xxs)) { + SectionOverline(stringResource(R.string.subtasks_title)) + ReorderableColumn( + list = subtasks, + onSettle = state::move, + onMove = { haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove) }, + ) { index, subtask, _ -> + key(subtask.key) { + SubtaskRow( + subtask = subtask, + state = state, + moveUp = { state.move(index, index - 1) }.takeIf { index > 0 }, + moveDown = { state.move(index, index + 1) }.takeIf { index < subtasks.lastIndex }, + ) + } + } + NewSubtaskRow(title = state.newTitle, onTitleChange = state::changeNewTitle, onAdd = state::add) + } +} + +@Composable +private fun ReorderableScope.SubtaskRow( + subtask: SubtaskDraft, + state: SubtaskListState, + moveUp: (() -> Unit)?, + moveDown: (() -> Unit)?, +) { + val moveUpLabel = stringResource(R.string.tag_move_up) + val moveDownLabel = stringResource(R.string.tag_move_down) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .semantics { + customActions = listOfNotNull( + moveUp?.let { up -> + CustomAccessibilityAction(moveUpLabel) { + up() + true + } + }, + moveDown?.let { down -> + CustomAccessibilityAction(moveDownLabel) { + down() + true + } + }, + ) + }, + ) { + EnclyCheckbox( + checked = subtask.isCompleted, + onCheckedChange = { state.setChecked(subtask.key, it) }, + size = CheckboxSize.SMALL, + ) + SubtaskTextField( + value = subtask.title, + onValueChange = { state.setTitle(subtask.key, it) }, + placeholder = stringResource(R.string.subtask_placeholder), + done = subtask.isCompleted, + modifier = Modifier.weight(1f), + ) + IconButton(onClick = { state.remove(subtask.key) }) { + Icon( + EnclyIcons.Close, + contentDescription = stringResource(R.string.subtask_delete), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(EnclyTheme.spacing.iconSmall), + ) + } + DragHandle(Modifier.draggableHandle()) + } +} + +/** The grip a row is dragged by; [modifier] carries the reorderable drag gesture. */ +@Composable +private fun DragHandle(modifier: Modifier = Modifier) { + val description = stringResource(R.string.tag_drag_handle) + Box( + contentAlignment = Alignment.Center, + modifier = modifier + .size(EnclyTheme.spacing.minTouchTarget) + .semantics { contentDescription = description }, + ) { + Icon( + EnclyIcons.Grip, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(EnclyTheme.spacing.iconSmall), + ) + } +} + +/** "Add sub-task": type a title and press the keyboard's action or the plus to add it. */ +@Composable +private fun NewSubtaskRow(title: String, onTitleChange: (String) -> Unit, onAdd: () -> Unit) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { + IconButton(onClick = onAdd, enabled = title.isNotBlank()) { + Icon( + EnclyIcons.Plus, + contentDescription = stringResource(R.string.subtask_add), + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(EnclyTheme.spacing.iconSmall), + ) + } + SubtaskTextField( + value = title, + onValueChange = onTitleChange, + placeholder = stringResource(R.string.subtask_add), + onImeAction = onAdd, + modifier = Modifier.weight(1f), + ) + } +} + +@Composable +private fun SubtaskTextField( + value: String, + onValueChange: (String) -> Unit, + placeholder: String, + modifier: Modifier = Modifier, + done: Boolean = false, + onImeAction: (() -> Unit)? = null, +) { + val colors = MaterialTheme.colorScheme + val style = MaterialTheme.typography.bodyMedium.copy( + color = if (done) colors.onSurfaceVariant else colors.onSurface, + textDecoration = if (done) TextDecoration.LineThrough else TextDecoration.None, + ) + BasicTextField( + value = value, + onValueChange = onValueChange, + textStyle = style, + singleLine = true, + cursorBrush = SolidColor(colors.primary), + // Same keyboard as the task's own fields. + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Done), + // The "add" field keeps the keyboard open, so several sub-tasks go in one after another. + keyboardActions = KeyboardActions(onDone = { onImeAction?.invoke() ?: defaultKeyboardAction(ImeAction.Done) }), + modifier = modifier, + decorationBox = { innerTextField -> + Box(modifier = Modifier.fillMaxWidth()) { + if (value.isEmpty()) { + Text(text = placeholder, style = style.copy(color = colors.onSurfaceVariant)) + } + innerTextField() + } + }, + ) +} diff --git a/app/src/main/java/com/pasich/encly/presentation/screen/TasksScreen.kt b/app/src/main/java/com/pasich/encly/presentation/screen/TasksScreen.kt index d8ea69f..b3ba636 100644 --- a/app/src/main/java/com/pasich/encly/presentation/screen/TasksScreen.kt +++ b/app/src/main/java/com/pasich/encly/presentation/screen/TasksScreen.kt @@ -100,9 +100,7 @@ fun TasksScreen( ) { padding -> LazyColumn( state = listState, - modifier = Modifier - .fillMaxSize() - .padding(padding), + modifier = Modifier.fillMaxSize().padding(padding), verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.xxs), contentPadding = tasksListPadding(), ) { @@ -124,6 +122,7 @@ fun TasksScreen( onEditTask = viewModel::editTask, onBackgroundSave = viewModel::saveDraftForBackground, onDeleteTask = viewModel::deleteTask, + editSubtasks = viewModel.editingSubtasks.collectAsState().value, sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), ) } @@ -169,7 +168,10 @@ private fun tasksListPadding() = PaddingValues( bottom = EnclyTheme.spacing.fabHeight + EnclyTheme.spacing.l + EnclyTheme.spacing.m, ) -/** Failures, and "Task deleted" with Undo, as snackbars, until the calling effect ends. */ +/** + * Failures, "Task deleted" with Undo, and "All sub-tasks done" with Complete task, as + * snackbars, until the calling effect ends. + */ private suspend fun showTaskMessages( viewModel: TasksViewModel, snackbarHostState: SnackbarHostState, @@ -190,6 +192,16 @@ private suspend fun showTaskMessages( if (result == SnackbarResult.ActionPerformed) viewModel.restoreTask(task) } } + launch { + viewModel.completionOffers.collect { taskId -> + val result = snackbarHostState.showSnackbar( + message = context.getString(R.string.subtasks_all_done), + actionLabel = context.getString(R.string.subtasks_complete_task), + duration = SnackbarDuration.Long, + ) + if (result == SnackbarResult.ActionPerformed) viewModel.toggleTaskCompletion(taskId, true) + } + } } /** The list: a skeleton while loading, else progress, chips, open tasks and the Done section. */ @@ -246,16 +258,30 @@ private fun LazyListScope.tasksContent( ) } } - items(open, key = { it.id }) { task -> - TaskItem(task = task, onTaskToggle = onToggle, onTaskClick = onOpen, modifier = Modifier.animateItem()) - } + taskRows(open, state, onToggle, onOpen) if (done.isNotEmpty()) { item(key = "done") { DoneHeader(count = done.size, onClear = onClearCompleted) } - items(done, key = { it.id }) { task -> - TaskItem(task = task, onTaskToggle = onToggle, onTaskClick = onOpen, modifier = Modifier.animateItem()) - } + taskRows(done, state, onToggle, onOpen) + } +} + +/** One tile per task, with its sub-task progress when it has sub-tasks. */ +private fun LazyListScope.taskRows( + tasks: List, + state: TasksUiState, + onToggle: (Long, Boolean) -> Unit, + onOpen: (Task) -> Unit, +) { + items(tasks, key = { it.id }) { task -> + TaskItem( + task = task, + onTaskToggle = onToggle, + onTaskClick = onOpen, + subtasks = state.subtaskProgress[task.id], + modifier = Modifier.animateItem(), + ) } } diff --git a/app/src/main/java/com/pasich/encly/presentation/viewmodel/SubtaskDrafts.kt b/app/src/main/java/com/pasich/encly/presentation/viewmodel/SubtaskDrafts.kt new file mode 100644 index 0000000..845eddf --- /dev/null +++ b/app/src/main/java/com/pasich/encly/presentation/viewmodel/SubtaskDrafts.kt @@ -0,0 +1,57 @@ +package com.pasich.encly.presentation.viewmodel + +import com.pasich.encly.data.model.Subtask + +/** + * One row of the task editor's checklist. [key] identifies the row while it is edited and + * dragged (a stored row uses its id, a new one a negative number); [id] and [uid] are 0 and + * blank until the row is saved. + */ +data class SubtaskDraft( + val key: Long, + val title: String, + val isCompleted: Boolean = false, + val id: Long = 0, + val uid: String = "", +) + +/** The editor checklist's edits and its mapping to stored rows, as pure functions. */ +internal object SubtaskDrafts { + fun fromSubtasks(subtasks: List): List = subtasks.map { + SubtaskDraft(key = it.id, title = it.title, isCompleted = it.isCompleted, id = it.id, uid = it.uid) + } + + /** [drafts] with the row at [from] moved to [to]; out-of-range indices change nothing. */ + fun move(drafts: List, from: Int, to: Int): List { + if (from !in drafts.indices || to !in drafts.indices || from == to) return drafts + return drafts.toMutableList().apply { add(to, removeAt(from)) } + } + + /** + * The rows to store for [taskId], in this order. A row left blank is dropped, like a task + * without a title is never saved. + */ + fun toSubtasks(taskId: Long, drafts: List): List = drafts + .filter { it.title.isNotBlank() } + .mapIndexed { index, draft -> + Subtask( + id = draft.id, + taskId = taskId, + title = draft.title.trim(), + isCompleted = draft.isCompleted, + position = index, + uid = draft.uid, + ) + } + + /** + * Whether a save should offer to complete the task: it is still open and this save ticked + * its last open sub-task. Neither direction is automatic: completing a task never ticks its + * sub-tasks, and ticking every sub-task only offers to complete the task. + */ + fun offersCompletion(taskCompleted: Boolean, before: List, after: List): Boolean { + val allDoneBefore = before.isNotEmpty() && before.all { it.isCompleted } + val allDoneAfter = after.isNotEmpty() && after.all { it.isCompleted } + return !taskCompleted && allDoneAfter && !allDoneBefore + } +} diff --git a/app/src/main/java/com/pasich/encly/presentation/viewmodel/TasksViewModel.kt b/app/src/main/java/com/pasich/encly/presentation/viewmodel/TasksViewModel.kt index 8d3970a..21cc329 100644 --- a/app/src/main/java/com/pasich/encly/presentation/viewmodel/TasksViewModel.kt +++ b/app/src/main/java/com/pasich/encly/presentation/viewmodel/TasksViewModel.kt @@ -5,6 +5,8 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.pasich.encly.core.security.NeverLocked import com.pasich.encly.core.security.VaultLockEvents +import com.pasich.encly.data.model.Subtask +import com.pasich.encly.data.model.SubtaskProgress import com.pasich.encly.data.model.Task import com.pasich.encly.domain.repository.TasksRepository import com.pasich.encly.domain.usecase.task.UpdateTaskStatusUseCase @@ -55,8 +57,16 @@ const val HOME_WIDGET_TASKS = 2 fun widgetTasks(tasks: List, limit: Int = HOME_WIDGET_TASKS): List = tasks.filterNot { it.isCompleted }.sortedByDescending { it.priority }.take(limit) -/** Unsaved content of the task editor sheet. */ -data class TaskDraft(val title: String, val description: String, val priority: Int) +/** + * Unsaved content of the task editor sheet. [subtasks] is null while the edited task's + * checklist has not loaded: the stored one is then left as it is. + */ +data class TaskDraft( + val title: String, + val description: String, + val priority: Int, + val subtasks: List? = null, +) data class TasksUiState( val activeTasks: List = emptyList(), @@ -70,6 +80,8 @@ data class TasksUiState( val selectedPriorityFilter: TaskFilter? = null, val selectedCompletedFilter: TaskFilter? = null, val filteredActiveTasks: List = emptyList(), + /** Sub-task progress by task id; a task without sub-tasks has no entry. */ + val subtaskProgress: Map = emptyMap(), val isLoading: Boolean = true, ) @@ -96,6 +108,22 @@ class TasksViewModel @Inject constructor( private val _editingTask = MutableStateFlow(null) val editingTask: StateFlow = _editingTask.asStateFlow() + /** The edited task's checklist: empty for a new task, null until an edited one has loaded. */ + private val _editingSubtasks = MutableStateFlow?>(emptyList()) + val editingSubtasks: StateFlow?> = _editingSubtasks.asStateFlow() + + /** + * A task whose last open sub-task was just ticked, so the screen can offer to complete it + * with one tap. The task is never completed without that tap. + */ + private val _completionOffers = MutableSharedFlow(extraBufferCapacity = 1) + val completionOffers: SharedFlow = _completionOffers.asSharedFlow() + + /** Sub-tasks of deleted tasks by task id, for [restoreTask]; the delete cascades to them. */ + private val deletedSubtasks = mutableMapOf>() + + private var subtasksJob: Job? = null + /** Serializes background draft saves so two quick pauses cannot insert twice. */ private val draftMutex = Mutex() @@ -105,8 +133,11 @@ class TasksViewModel @Inject constructor( observeTasks() clearOnLock(lockEvents) { tasksJob?.cancel() + subtasksJob?.cancel() _uiState.value = TasksUiState() _editingTask.value = null + _editingSubtasks.value = emptyList() + deletedSubtasks.clear() _showAddTaskDialog.value = false } } @@ -118,8 +149,10 @@ class TasksViewModel @Inject constructor( tasksRepository.getAllCompletedTasks(), tasksRepository.getActiveTasksCount(), tasksRepository.getCompletedTasksCount(), - ) { activeTasks, completedTasks, activeCount, completedCount -> + tasksRepository.getSubtaskProgress(), + ) { activeTasks, completedTasks, activeCount, completedCount, progress -> TaskFilterEngine.reduce(_uiState.value, activeTasks, completedTasks, activeCount, completedCount) + .copy(subtaskProgress = progress.associateBy { it.taskId }) }.collect { newState -> _uiState.value = newState } @@ -127,21 +160,38 @@ class TasksViewModel @Inject constructor( } fun showAddTaskDialog() { + subtasksJob?.cancel() _editingTask.value = null + _editingSubtasks.value = emptyList() _showAddTaskDialog.value = true } fun showEditTaskDialog(task: Task) { + subtasksJob?.cancel() _editingTask.value = task + // Unknown until loaded: a sheet saved before then leaves the stored checklist alone, + // and one that fails to load never overwrites it with an empty list. + _editingSubtasks.value = null _showAddTaskDialog.value = true + subtasksJob = viewModelScope.launch { + tasksRepository.getSubtasks(task.id).onSuccess { _editingSubtasks.value = SubtaskDrafts.fromSubtasks(it) } + } } fun hideAddTaskDialog() { + subtasksJob?.cancel() _showAddTaskDialog.value = false _editingTask.value = null + _editingSubtasks.value = emptyList() } - fun addTask(title: String, description: String?, priority: Int, categoryId: Long? = null) { + fun addTask( + title: String, + description: String?, + priority: Int, + subtasks: List = emptyList(), + categoryId: Long? = null, + ) { viewModelScope.launch { val task = Task.new( title = title, @@ -149,15 +199,31 @@ class TasksViewModel @Inject constructor( priority = priority, categoryId = categoryId, ) - if (tasksRepository.insertTask(task).isSuccess) { - hideAddTaskDialog() - } else { + val id = tasksRepository.insertTask(task).getOrNull() + if (id == null) { _operationFailures.emit(TaskOperationFailure.CREATE) + return@launch } + val rows = SubtaskDrafts.toSubtasks(id, subtasks) + if (rows.isNotEmpty() && tasksRepository.saveSubtasks(id, rows).isFailure) { + _operationFailures.emit(TaskOperationFailure.CREATE) + return@launch + } + hideAddTaskDialog() + if (SubtaskDrafts.offersCompletion(task.isCompleted, emptyList(), rows)) _completionOffers.emit(id) } } - fun editTask(taskId: Long, title: String, description: String?, priority: Int, categoryId: Long? = null) { + /** [subtasks] null leaves the stored checklist as it is. */ + @Suppress("LongParameterList") // The editor's fields, each optional for the callers that lack it. + fun editTask( + taskId: Long, + title: String, + description: String?, + priority: Int, + subtasks: List? = null, + categoryId: Long? = null, + ) { viewModelScope.launch { val existingTask = uiState.value.activeTasks.find { it.id == taskId } ?: uiState.value.completedTasks.find { it.id == taskId } @@ -175,14 +241,29 @@ class TasksViewModel @Inject constructor( categoryId = categoryId ?: existingTask.categoryId, ) - if (tasksRepository.updateTask(updatedTask).isSuccess) { - hideAddTaskDialog() - } else { + if (tasksRepository.updateTask(updatedTask).isFailure) { _operationFailures.emit(TaskOperationFailure.UPDATE) + return@launch } + if (subtasks != null && !saveEditedSubtasks(updatedTask, subtasks)) { + _operationFailures.emit(TaskOperationFailure.UPDATE) + return@launch + } + hideAddTaskDialog() } } + /** Stores the edited checklist and, when that ticked the last open sub-task, offers to complete the task. */ + private suspend fun saveEditedSubtasks(task: Task, drafts: List): Boolean { + val after = SubtaskDrafts.toSubtasks(task.id, drafts) + val before = tasksRepository.getSubtasks(task.id).getOrNull() + val saved = before != null && (before == after || tasksRepository.saveSubtasks(task.id, after).isSuccess) + if (saved && SubtaskDrafts.offersCompletion(task.isCompleted, before.orEmpty(), after)) { + _completionOffers.emit(task.id) + } + return saved + } + /** * Persists the open editor when the app leaves the foreground. * @@ -199,6 +280,15 @@ class TasksViewModel @Inject constructor( } private suspend fun persistDraft(draft: TaskDraft) { + val taskId = persistDraftTask(draft) ?: return + val subtasks = draft.subtasks ?: return + if (tasksRepository.saveSubtasks(taskId, SubtaskDrafts.toSubtasks(taskId, subtasks)).isFailure) { + _operationFailures.emit(TaskOperationFailure.UPDATE) + } + } + + /** Inserts or updates the draft's task; its id, or null when the write failed. */ + private suspend fun persistDraftTask(draft: TaskDraft): Long? { val description = draft.description.ifBlank { null } val editing = _editingTask.value if (editing == null) { @@ -207,22 +297,19 @@ class TasksViewModel @Inject constructor( description = description, priority = draft.priority, ) - tasksRepository.insertTask(task) + return tasksRepository.insertTask(task) .onSuccess { id -> _editingTask.value = task.copy(id = id) } .onFailure { _operationFailures.emit(TaskOperationFailure.CREATE) } - } else { - val updated = editing.copy( - title = draft.title, - description = description, - priority = draft.priority, - ) - if (updated == editing) return - if (tasksRepository.updateTask(updated).isSuccess) { - _editingTask.value = updated - } else { - _operationFailures.emit(TaskOperationFailure.UPDATE) - } + .getOrNull() } + val updated = editing.copy( + title = draft.title, + description = description, + priority = draft.priority, + ) + val saved = updated == editing || tasksRepository.updateTask(updated).isSuccess + if (saved) _editingTask.value = updated else _operationFailures.emit(TaskOperationFailure.UPDATE) + return editing.id.takeIf { saved } } fun toggleTaskCompletion(taskId: Long, isCompleted: Boolean) { @@ -235,7 +322,10 @@ class TasksViewModel @Inject constructor( fun deleteTask(task: Task) { viewModelScope.launch { + // Read before the delete cascades to them, so Undo can bring them back. + val subtasks = tasksRepository.getSubtasks(task.id).getOrDefault(emptyList()) if (tasksRepository.deleteTaskById(task.id).isSuccess) { + deletedSubtasks[task.id] = subtasks hideAddTaskDialog() _deletedTasks.emit(task) } else { @@ -244,10 +334,13 @@ class TasksViewModel @Inject constructor( } } - /** Undo for [deleteTask]: puts the same task (id, uid, dates) back. */ + /** Undo for [deleteTask]: puts the same task (id, uid, dates) back, with its sub-tasks. */ fun restoreTask(task: Task) { viewModelScope.launch { - if (tasksRepository.insertTask(task).isFailure) { + val subtasks = deletedSubtasks[task.id].orEmpty() + if (tasksRepository.restoreTask(task, subtasks).isSuccess) { + deletedSubtasks.remove(task.id) + } else { _operationFailures.emit(TaskOperationFailure.CREATE) } } diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index b0e0d43..80fcf9e 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -553,4 +553,10 @@ Tastatur ausblenden Alle Daten löschen? Löschen + Unteraufgaben + Unteraufgabe + Unteraufgabe hinzufügen + Unteraufgabe löschen + Alle Unteraufgaben sind erledigt + Aufgabe erledigen diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 828ba95..7bf8a80 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -558,4 +558,10 @@ Ocultar teclado ¿Borrar todos los datos? Borrar + Subtareas + Subtarea + Añadir subtarea + Eliminar subtarea + Todas las subtareas están hechas + Completar tarea diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index ccd82a6..04276f7 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -558,4 +558,10 @@ Masquer le clavier Effacer toutes les données ? Effacer + Sous-tâches + Sous-tâche + Ajouter une sous-tâche + Supprimer la sous-tâche + Toutes les sous-tâches sont terminées + Terminer la tâche diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index ebc0a9d..7440680 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -558,4 +558,10 @@ Nascondi tastiera Cancellare tutti i dati? Cancella + Sottoattività + Sottoattività + Aggiungi sottoattività + Elimina sottoattività + Tutte le sottoattività sono completate + Completa l\'attività diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index cbeb94b..55e0c1a 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -553,4 +553,10 @@ Toetsenbord verbergen Alle gegevens wissen? Wissen + Subtaken + Subtaak + Subtaak toevoegen + Subtaak verwijderen + Alle subtaken zijn klaar + Taak voltooien diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 3e01d4d..47ec49c 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -563,4 +563,10 @@ Ukryj klawiaturę Wymazać wszystkie dane? Wymaż + Podzadania + Podzadanie + Dodaj podzadanie + Usuń podzadanie + Wszystkie podzadania wykonane + Wykonaj zadanie diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index cec5a1c..2ddf104 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -558,4 +558,10 @@ Ocultar teclado Apagar todos os dados? Apagar + Subtarefas + Subtarefa + Adicionar subtarefa + Excluir subtarefa + Todas as subtarefas estão concluídas + Concluir tarefa diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 2a326cd..1c72c96 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -563,4 +563,10 @@ Сховати клавіатуру Стерти всі дані? Стерти + Підзавдання + Підзавдання + Додати підзавдання + Видалити підзавдання + Усі підзавдання виконано + Завершити завдання diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6b75428..97015be 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -570,4 +570,11 @@ Hide keyboard Erase all data? Erase + Sub-tasks + Sub-task + Add sub-task + Delete sub-task + All sub-tasks are done + Complete task + %1$d/%2$d diff --git a/app/src/test/java/com/pasich/encly/core/backup/BackupPayloadCodecTest.kt b/app/src/test/java/com/pasich/encly/core/backup/BackupPayloadCodecTest.kt index 51e06ab..ec98c2e 100644 --- a/app/src/test/java/com/pasich/encly/core/backup/BackupPayloadCodecTest.kt +++ b/app/src/test/java/com/pasich/encly/core/backup/BackupPayloadCodecTest.kt @@ -13,15 +13,36 @@ class BackupPayloadCodecTest { BackupNote("n1", "Title", "[]", "desc", 2L, 1L, tagUid = "t1", isTrash = true), ), tasks = listOf( - BackupTask("k1", "Task", null, false, 3L, null, priority = 2, categoryTagUid = "t1", position = 5), + BackupTask( + "k1", + "Task", + null, + false, + 3L, + null, + priority = 2, + categoryTagUid = "t1", + position = 5, + subtasks = emptyList(), + ), ), ) + private val subtasks = listOf( + BackupSubtask(uid = "s1", title = "Milk", isCompleted = true, position = 0), + BackupSubtask(uid = "s2", title = "Bread", isCompleted = false, position = 1), + ) + private val withSubtasks = payload.copy(tasks = payload.tasks.map { it.copy(subtasks = subtasks) }) @Test fun encodeDecodeRoundTrips() { assertEquals(payload, BackupPayloadCodec.decode(BackupPayloadCodec.encode(payload))) } + @Test + fun subtasksRoundTrip() { + assertEquals(withSubtasks, BackupPayloadCodec.decode(BackupPayloadCodec.encode(withSubtasks))) + } + @Test fun emptyVaultRoundTrips() { val empty = BackupPayload(exportedAt = 0, tags = emptyList(), notes = emptyList(), tasks = emptyList()) @@ -69,6 +90,29 @@ class BackupPayloadCodecTest { assertEquals(BackupPayload.SCHEMA_VERSION, decoded.schema) } + @Test + fun schema2BackupDecodesWithNoSubtasks() { + val decoded = BackupPayloadCodec.decode(schema2Json().toByteArray(Charsets.UTF_8)) + + assertEquals(payload, decoded) + assertEquals(BackupPayload.SCHEMA_VERSION, decoded.schema) + } + + @Test + fun schema2BackupWithSubtasksIsInvalid() { + // Schema 2 never had sub-tasks: such a file is malformed, not a newer one. + val json = schema2Json().replace("\"position\":5}", "\"position\":5,\"subtasks\":[]}") + + assertError(BackupError.INVALID_PAYLOAD) { BackupPayloadCodec.decode(json.toByteArray(Charsets.UTF_8)) } + } + + @Test + fun currentSchemaRequiresTheSubtasksList() { + val json = String(BackupPayloadCodec.encode(payload), Charsets.UTF_8).replace(",\"subtasks\":[]", "") + + assertError(BackupError.INVALID_PAYLOAD) { BackupPayloadCodec.decode(json.toByteArray(Charsets.UTF_8)) } + } + @Test fun schema1BackupStillRejectsOtherUnknownFields() { val json = schema1Json().replaceFirst("{", "{\"pinnedNotes\":[\"n1\"],") @@ -90,14 +134,16 @@ class BackupPayloadCodecTest { */ @Test fun encodedKeyNamesArePinned() { - val expected = """{"schema":2,"exportedAt":1700000000000,""" + + val expected = """{"schema":3,"exportedAt":1700000000000,""" + """"tags":[{"uid":"t1","name":"Work","visible":true,"position":0}],""" + """"notes":[{"uid":"n1","title":"Title","value":"[]","description":"desc","date":2,""" + """"dateCreate":1,"tagUid":"t1","isTrash":true}],""" + """"tasks":[{"uid":"k1","title":"Task","description":null,"isCompleted":false,""" + - """"createdDate":3,"completedDate":null,"priority":2,"categoryTagUid":"t1","position":5}]}""" + """"createdDate":3,"completedDate":null,"priority":2,"categoryTagUid":"t1","position":5,""" + + """"subtasks":[{"uid":"s1","title":"Milk","isCompleted":true,"position":0},""" + + """{"uid":"s2","title":"Bread","isCompleted":false,"position":1}]}]}""" - assertEquals(expected, String(BackupPayloadCodec.encode(payload), Charsets.UTF_8)) + assertEquals(expected, String(BackupPayloadCodec.encode(withSubtasks), Charsets.UTF_8)) } /** [payload] as the schema-1 app wrote it: every task carried a `reminderDate`. */ @@ -109,6 +155,14 @@ class BackupPayloadCodecTest { """"createdDate":3,"completedDate":null,"reminderDate":4,"priority":2,""" + """"categoryTagUid":"t1","position":5}]}""" + /** [payload] as the schema-2 app wrote it: tasks had no `subtasks`. */ + private fun schema2Json(): String = """{"schema":2,"exportedAt":1700000000000,""" + + """"tags":[{"uid":"t1","name":"Work","visible":true,"position":0}],""" + + """"notes":[{"uid":"n1","title":"Title","value":"[]","description":"desc","date":2,""" + + """"dateCreate":1,"tagUid":"t1","isTrash":true}],""" + + """"tasks":[{"uid":"k1","title":"Task","description":null,"isCompleted":false,""" + + """"createdDate":3,"completedDate":null,"priority":2,"categoryTagUid":"t1","position":5}]}""" + private fun withExtraField(value: BackupPayload): ByteArray { val json = String(BackupPayloadCodec.encode(value), Charsets.UTF_8) return json.replaceFirst("{", "{\"pinnedNotes\":[\"n1\"],").toByteArray(Charsets.UTF_8) @@ -128,6 +182,27 @@ class BackupPayloadCodecTest { assertInvalid(payload.copy(tasks = payload.tasks.map { it.copy(uid = "x".repeat(65)) })) } + @Test + fun subtaskUidsMustBeUniqueAcrossTheVaultAndPresent() { + val second = payload.tasks.single().copy(uid = "k2", subtasks = listOf(subtasks.first())) + assertInvalid(withSubtasks.copy(tasks = withSubtasks.tasks + second)) + assertInvalid(payload.copy(tasks = payload.tasks.map { it.copy(subtasks = subtasks + subtasks) })) + assertInvalid( + payload.copy( + tasks = payload.tasks.map { + it.copy(subtasks = listOf(subtasks[0].copy(uid = ""))) + }, + ), + ) + assertInvalid( + payload.copy( + tasks = payload.tasks.map { + it.copy(subtasks = listOf(subtasks[0].copy(uid = "x".repeat(65)))) + }, + ), + ) + } + @Test fun priorityMustBeKnown() { assertInvalid(payload.copy(tasks = payload.tasks.map { it.copy(priority = 3) })) diff --git a/app/src/test/java/com/pasich/encly/data/backup/BackupImporterTest.kt b/app/src/test/java/com/pasich/encly/data/backup/BackupImporterTest.kt index 1852e08..6fea06e 100644 --- a/app/src/test/java/com/pasich/encly/data/backup/BackupImporterTest.kt +++ b/app/src/test/java/com/pasich/encly/data/backup/BackupImporterTest.kt @@ -10,6 +10,7 @@ import com.pasich.encly.core.backup.BackupSecret import com.pasich.encly.core.backup.assertError import com.pasich.encly.core.serialization.BlockConverter import com.pasich.encly.data.model.Note +import com.pasich.encly.data.model.Subtask import com.pasich.encly.data.model.Tag import com.pasich.encly.data.model.Task import com.pasich.encly.domain.model.ItemListBlock @@ -76,6 +77,14 @@ class BackupImporterTest { insertTask(Task(title = "Done", isCompleted = true, createdDate = 1, completedDate = 2, uid = "k-done")) } + /** [seededVault] where "Call" has a checklist of three, one of them done. */ + private suspend fun seededVaultWithSubtasks() = seededVault().apply { + val call = tasks.single { it.uid == "k-call" }.id + insertSubtask(Subtask(taskId = call, title = "Bread", position = 1, uid = "s-bread")) + insertSubtask(Subtask(taskId = call, title = "Milk", isCompleted = true, position = 0, uid = "s-milk")) + insertSubtask(Subtask(taskId = call, title = "Eggs", position = 2, uid = "s-eggs")) + } + /** Export exactly as the app does: snapshot -> payload -> JSON -> sealed file. */ private suspend fun export(store: InMemoryVaultDataStore): ByteArray { val plaintext = BackupPayloadCodec.encode(BackupMapper.toPayload(store.snapshot(), exportedAt = 42)) @@ -97,6 +106,70 @@ class BackupImporterTest { assertEquals(allBlocks, restored.notes.single { it.uid == "n-plan" }.value) } + @Test + fun subtasksSurviveExportAndReplaceImport() = runTest { + val source = seededVaultWithSubtasks() + val restored = InMemoryVaultDataStore().apply { + val local = insertTask(Task(title = "Local", uid = "k-local")) + insertSubtask(Subtask(taskId = local, title = "Gone after replace", uid = "s-local")) + } + + val payload = open(export(source)) + val summary = BackupImporter.import(payload, ImportMode.REPLACE, restored) + + assertEquals( + listOf("s-milk", "s-bread", "s-eggs"), + payload.tasks.single { + it.uid == "k-call" + }.subtasks.map { it.uid }, + ) + assertEquals( + ImportSummary(notesAdded = 2, tagsAdded = 2, tasksAdded = 2, skipped = 0, subtasksAdded = 3), + summary, + ) + assertEquals(portable(source), portable(restored)) + assertEquals(setOf("s-milk", "s-bread", "s-eggs"), restored.subtasks.map { it.uid }.toSet()) + } + + @Test + fun mergeAddsTheMissingSubtasksOfATaskThatIsAlreadyHere() = runTest { + val payload = open(export(seededVaultWithSubtasks())) + val target = InMemoryVaultDataStore().apply { + val call = insertTask(Task(title = "Call (edited here)", uid = "k-call")) + insertSubtask(Subtask(taskId = call, title = "Mine", position = 0, uid = "s-mine")) + insertSubtask(Subtask(taskId = call, title = "Milk (edited here)", position = 1, uid = "s-milk")) + } + + val summary = BackupImporter.import(payload, ImportMode.MERGE, target) + + assertEquals(1, summary.tasksAdded) + assertEquals(2, summary.subtasksAdded) + val call = target.tasks.single { it.uid == "k-call" } + assertEquals("Call (edited here)", call.title) + // The local ones stay first and win; the missing ones follow in their backup order. + assertEquals( + listOf("Mine" to 0, "Milk (edited here)" to 1, "Bread" to 2, "Eggs" to 3), + target.subtasks.filter { it.taskId == call.id }.sortedBy { it.position }.map { it.title to it.position }, + ) + + val again = BackupImporter.import(payload, ImportMode.MERGE, target) + assertEquals(0, again.subtasksAdded) + assertEquals(4, target.subtasks.size) + } + + @Test + fun mergeBringsANewTaskWithItsSubtasks() = runTest { + val payload = open(export(seededVaultWithSubtasks())) + val target = InMemoryVaultDataStore() + + val summary = BackupImporter.import(payload, ImportMode.MERGE, target) + + assertEquals(3, summary.subtasksAdded) + val call = target.tasks.single { it.uid == "k-call" }.id + assertTrue(target.subtasks.all { it.taskId == call }) + assertEquals(listOf("Milk", "Bread", "Eggs"), target.subtasks.sortedBy { it.position }.map { it.title }) + } + @Test fun emptyVaultRoundTrips() = runTest { val restored = InMemoryVaultDataStore() @@ -191,10 +264,11 @@ class BackupImporterTest { assertError(BackupError.WRONG_SECRET) { BackupCipher.open(file, BackupSecret.RecoveryPhrase(other)) } } - /** Vault content without the device-local autoincrement ids: links become tag uids. */ + /** Vault content without the device-local autoincrement ids: links become tag and task uids. */ private fun portable(store: InMemoryVaultDataStore): Any { val tagUid = store.tags.associate { it.id to it.uid } - return Triple( + val taskUid = store.tasks.associate { it.id to it.uid } + return listOf( store.tags.map { listOf(it.uid, it.nameTag, it.isVisible, it.position) }.toSet(), store.notes.map { listOf( @@ -214,6 +288,7 @@ class BackupImporterTest { it.priority, tagUid[it.categoryId], it.position, ) }.toSet(), + store.subtasks.map { listOf(it.uid, taskUid[it.taskId], it.title, it.isCompleted, it.position) }.toSet(), ) } } diff --git a/app/src/test/java/com/pasich/encly/data/database/SubtasksDaoTest.kt b/app/src/test/java/com/pasich/encly/data/database/SubtasksDaoTest.kt new file mode 100644 index 0000000..60bcfb1 --- /dev/null +++ b/app/src/test/java/com/pasich/encly/data/database/SubtasksDaoTest.kt @@ -0,0 +1,134 @@ +package com.pasich.encly.data.database + +import android.app.Application +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import com.pasich.encly.data.model.Subtask +import com.pasich.encly.data.model.SubtaskProgress +import com.pasich.encly.data.model.Task +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import org.junit.After +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.annotation.Config + +/** + * Sub-tasks in the real Room schema (plain SQLite under Robolectric, not SQLCipher): the + * cascade on delete, the uid trigger, and saving an edited checklist in place. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35], application = Application::class) +class SubtasksDaoTest { + private lateinit var db: AppDatabase + private val dao get() = db.tasksDao() + + @Before + fun setUp() { + db = VaultSchema.install( + Room.inMemoryDatabaseBuilder(ApplicationProvider.getApplicationContext(), AppDatabase::class.java) + .allowMainThreadQueries(), + ).build() + } + + @After + fun tearDown() { + db.close() + } + + @Test + fun deletingATaskDeletesItsSubtasksOnly() = runBlocking { + val shop = dao.insertTask(Task(title = "Shop")) + val call = dao.insertTask(Task(title = "Call")) + dao.replaceSubtasks( + shop, + listOf(Subtask(taskId = shop, title = "Milk"), Subtask(taskId = shop, title = "Eggs")), + ) + dao.replaceSubtasks(call, listOf(Subtask(taskId = call, title = "Mom"))) + + dao.deleteTaskById(shop) + + assertTrue(dao.getSubtasks(shop).isEmpty()) + assertEquals(listOf("Mom"), dao.getSubtasks(call).map { it.title }) + assertEquals(listOf("Mom"), db.backupDao().allSubtasks().map { it.title }) + } + + @Test + fun clearingCompletedTasksDeletesTheirSubtasks() = runBlocking { + val done = dao.insertTask(Task(title = "Done", isCompleted = true)) + dao.replaceSubtasks(done, listOf(Subtask(taskId = done, title = "Step"))) + + dao.deleteAllCompletedTasks() + + assertTrue(db.backupDao().allSubtasks().isEmpty()) + } + + @Test + fun aNewSubtaskGetsAUidAndKeepsItWhenEdited() = runBlocking { + val task = dao.insertTask(Task(title = "Shop")) + dao.replaceSubtasks(task, listOf(Subtask(taskId = task, title = "Milk"))) + val saved = dao.getSubtasks(task).single() + assertEquals(32, saved.uid.length) + + // The editor round-trips rows it loaded; a row built without its uid keeps the stored one. + dao.replaceSubtasks(task, listOf(saved.copy(title = "Oat milk", isCompleted = true, uid = ""))) + + val edited = dao.getSubtasks(task).single() + assertEquals(saved.id, edited.id) + assertEquals(saved.uid, edited.uid) + assertEquals("Oat milk", edited.title) + assertTrue(edited.isCompleted) + } + + @Test + fun savingAChecklistReordersUpdatesAddsAndRemovesInOneGo() = runBlocking { + val task = dao.insertTask(Task(title = "Trip")) + dao.replaceSubtasks( + task, + listOf("Tickets", "Hotel", "Bags").map { Subtask(taskId = task, title = it) }, + ) + val (tickets, hotel, bags) = dao.getSubtasks(task) + + dao.replaceSubtasks(task, listOf(bags, Subtask(taskId = task, title = "Passport"), tickets)) + + val result = dao.getSubtasks(task) + assertEquals(listOf("Bags", "Passport", "Tickets"), result.map { it.title }) + assertEquals(listOf(0, 1, 2), result.map { it.position }) + assertEquals(listOf(bags.uid, tickets.uid), listOf(result[0].uid, result[2].uid)) + assertTrue(result.none { it.id == hotel.id }) + } + + @Test + fun progressCountsDoneAndTotalPerTask() = runBlocking { + val task = dao.insertTask(Task(title = "Trip")) + dao.insertTask(Task(title = "No checklist")) + dao.replaceSubtasks( + task, + listOf( + Subtask(taskId = task, title = "a", isCompleted = true), + Subtask(taskId = task, title = "b"), + Subtask(taskId = task, title = "c", isCompleted = true), + ), + ) + + assertEquals(listOf(SubtaskProgress(task, done = 2, total = 3)), dao.getSubtaskProgress().first()) + } + + @Test + fun undoPutsTheTaskBackWithTheSameSubtasks() = runBlocking { + val id = dao.insertTask(Task(title = "Shop")) + dao.replaceSubtasks(id, listOf(Subtask(taskId = id, title = "Milk", isCompleted = true))) + val task = dao.getTaskById(id)!! + val subtasks = dao.getSubtasks(id) + dao.deleteTaskById(id) + + dao.restoreTask(task, subtasks) + + assertEquals(task, dao.getTaskById(id)) + assertEquals(subtasks, dao.getSubtasks(id)) + } +} diff --git a/app/src/test/java/com/pasich/encly/presentation/viewmodel/SubtaskDraftsTest.kt b/app/src/test/java/com/pasich/encly/presentation/viewmodel/SubtaskDraftsTest.kt new file mode 100644 index 0000000..baed148 --- /dev/null +++ b/app/src/test/java/com/pasich/encly/presentation/viewmodel/SubtaskDraftsTest.kt @@ -0,0 +1,72 @@ +package com.pasich.encly.presentation.viewmodel + +import com.pasich.encly.data.model.Subtask +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test + +class SubtaskDraftsTest { + private val drafts = listOf("a", "b", "c", "d").mapIndexed { i, title -> + SubtaskDraft(key = i.toLong(), title = title) + } + + @Test + fun moveDownAndUp() { + assertEquals(listOf("b", "c", "a", "d"), SubtaskDrafts.move(drafts, 0, 2).map { it.title }) + assertEquals(listOf("d", "a", "b", "c"), SubtaskDrafts.move(drafts, 3, 0).map { it.title }) + assertEquals(listOf("a", "c", "b", "d"), SubtaskDrafts.move(drafts, 1, 2).map { it.title }) + } + + @Test + fun moveOutOfRangeOrInPlaceChangesNothing() { + assertSame(drafts, SubtaskDrafts.move(drafts, 1, 1)) + assertSame(drafts, SubtaskDrafts.move(drafts, -1, 2)) + assertSame(drafts, SubtaskDrafts.move(drafts, 0, 4)) + } + + @Test + fun savedRowsFollowTheEditorOrderAndDropBlankOnes() { + val edited = listOf( + SubtaskDraft(key = 7, title = " Milk ", isCompleted = true, id = 7, uid = "u7"), + SubtaskDraft(key = -1, title = " "), + SubtaskDraft(key = -2, title = "Eggs"), + ) + + assertEquals( + listOf( + Subtask(id = 7, taskId = 3, title = "Milk", isCompleted = true, position = 0, uid = "u7"), + Subtask(id = 0, taskId = 3, title = "Eggs", isCompleted = false, position = 1, uid = ""), + ), + SubtaskDrafts.toSubtasks(3, edited), + ) + } + + @Test + fun loadedRowsKeepTheirIdentity() { + val row = Subtask(id = 4, taskId = 1, title = "Milk", isCompleted = true, position = 0, uid = "u4") + + assertEquals( + listOf(SubtaskDraft(key = 4, title = "Milk", isCompleted = true, id = 4, uid = "u4")), + SubtaskDrafts.fromSubtasks(listOf(row)), + ) + } + + @Test + fun completionIsOfferedOnlyWhenTheLastOpenSubtaskIsTicked() { + val open = listOf(sub(done = true), sub(done = false)) + val allDone = listOf(sub(done = true), sub(done = true)) + + assertTrue(SubtaskDrafts.offersCompletion(taskCompleted = false, before = open, after = allDone)) + // Already all done before: nothing new to offer. + assertFalse(SubtaskDrafts.offersCompletion(taskCompleted = false, before = allDone, after = allDone)) + // The task is already completed. + assertFalse(SubtaskDrafts.offersCompletion(taskCompleted = true, before = open, after = allDone)) + // Something still open, or no checklist at all. + assertFalse(SubtaskDrafts.offersCompletion(taskCompleted = false, before = open, after = open)) + assertFalse(SubtaskDrafts.offersCompletion(taskCompleted = false, before = open, after = emptyList())) + } + + private fun sub(done: Boolean) = Subtask(taskId = 1, title = "x", isCompleted = done) +} diff --git a/app/src/test/java/com/pasich/encly/presentation/viewmodel/TasksViewModelDraftTest.kt b/app/src/test/java/com/pasich/encly/presentation/viewmodel/TasksViewModelDraftTest.kt index 68caad2..41123f4 100644 --- a/app/src/test/java/com/pasich/encly/presentation/viewmodel/TasksViewModelDraftTest.kt +++ b/app/src/test/java/com/pasich/encly/presentation/viewmodel/TasksViewModelDraftTest.kt @@ -1,5 +1,7 @@ package com.pasich.encly.presentation.viewmodel +import com.pasich.encly.data.model.Subtask +import com.pasich.encly.data.model.SubtaskProgress import com.pasich.encly.data.model.Task import com.pasich.encly.domain.repository.TasksRepository import com.pasich.encly.domain.usecase.task.UpdateTaskStatusUseCase @@ -7,6 +9,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import kotlinx.coroutines.test.StandardTestDispatcher @@ -170,6 +173,7 @@ private const val CATEGORY_ID = 4L private class InMemoryTasksRepository : TasksRepository { val tasks = MutableStateFlow>(emptyList()) + var subtasks = emptyList() private var nextId = 1L override fun getAllActiveTasks(): Flow> = tasks.map { l -> l.filterNot { it.isCompleted } } @@ -195,6 +199,21 @@ private class InMemoryTasksRepository : TasksRepository { } override suspend fun deleteAllCompletedTasks(): Result = Result.success(Unit) + + override fun getSubtaskProgress(): Flow> = flowOf(emptyList()) + + override suspend fun getSubtasks(taskId: Long): Result> = + Result.success(subtasks.filter { it.taskId == taskId }) + + override suspend fun saveSubtasks(taskId: Long, subtasks: List): Result { + this.subtasks = this.subtasks.filterNot { it.taskId == taskId } + subtasks + return Result.success(Unit) + } + + override suspend fun restoreTask(task: Task, subtasks: List): Result { + tasks.value = tasks.value + task + return Result.success(Unit) + } override suspend fun deleteTaskById(id: Long): Result = runCatching { check(tasks.value.any { it.id == id }) tasks.value = tasks.value.filterNot { it.id == id } diff --git a/app/src/test/java/com/pasich/encly/presentation/viewmodel/TasksViewModelSubtasksTest.kt b/app/src/test/java/com/pasich/encly/presentation/viewmodel/TasksViewModelSubtasksTest.kt new file mode 100644 index 0000000..e4983a7 --- /dev/null +++ b/app/src/test/java/com/pasich/encly/presentation/viewmodel/TasksViewModelSubtasksTest.kt @@ -0,0 +1,194 @@ +package com.pasich.encly.presentation.viewmodel + +import com.pasich.encly.data.model.Subtask +import com.pasich.encly.data.model.SubtaskProgress +import com.pasich.encly.data.model.Task +import com.pasich.encly.domain.usecase.task.UpdateTaskStatusUseCase +import com.pasich.encly.testutil.TestTasksRepository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +/** Sub-tasks on the Tasks screen: editing, progress, the completion offer, delete and undo. */ +@OptIn(ExperimentalCoroutinesApi::class) +class TasksViewModelSubtasksTest { + private val dispatcher = StandardTestDispatcher() + private lateinit var repository: TestTasksRepository + private lateinit var viewModel: TasksViewModel + + @Before + fun setUp() { + Dispatchers.setMain(dispatcher) + repository = TestTasksRepository( + initial = listOf(Task(id = 1, title = "Shop"), Task(id = 2, title = "Call")), + initialSubtasks = listOf( + Subtask(id = 10, taskId = 1, title = "Milk", isCompleted = true, position = 0, uid = "u10"), + Subtask(id = 11, taskId = 1, title = "Eggs", position = 1, uid = "u11"), + ), + ) + viewModel = TasksViewModel(repository, UpdateTaskStatusUseCase(repository)) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun theTileProgressCountsDoneAndTotal() = runTest(dispatcher) { + advanceUntilIdle() + + assertEquals(mapOf(1L to SubtaskProgress(1, done = 1, total = 2)), viewModel.uiState.value.subtaskProgress) + } + + @Test + fun editingLoadsTheChecklistAfterTheSheetOpens() = runTest(dispatcher) { + viewModel.showEditTaskDialog(repository.tasks.value.first()) + assertNull(viewModel.editingSubtasks.value) + + advanceUntilIdle() + + assertEquals(listOf("Milk", "Eggs"), viewModel.editingSubtasks.value?.map { it.title }) + + viewModel.showAddTaskDialog() + assertEquals(emptyList(), viewModel.editingSubtasks.value) + } + + @Test + fun aNewTaskIsSavedWithItsSubtasks() = runTest(dispatcher) { + viewModel.addTask( + "Trip", + null, + priority = 0, + subtasks = listOf(SubtaskDraft(key = -1, title = "Tickets"), SubtaskDraft(key = -2, title = " ")), + ) + advanceUntilIdle() + + val trip = repository.tasks.value.single { it.title == "Trip" } + assertEquals(listOf("Tickets"), repository.subtasks.value.filter { it.taskId == trip.id }.map { it.title }) + assertFalse(viewModel.showAddTaskDialog.value) + } + + @Test + fun editingWithoutALoadedChecklistLeavesItAlone() = runTest(dispatcher) { + advanceUntilIdle() + viewModel.editTask(1, "Shop!", null, priority = 0) + advanceUntilIdle() + + assertEquals(listOf("Milk", "Eggs"), repository.subtasks.value.map { it.title }) + } + + @Test + fun tickingTheLastOpenSubtaskOffersToCompleteButDoesNotComplete() = runTest(dispatcher) { + val offers = collectOffers() + viewModel.showEditTaskDialog(repository.tasks.value.first()) + advanceUntilIdle() + val ticked = viewModel.editingSubtasks.value!!.map { it.copy(isCompleted = true) } + + viewModel.editTask(1, "Shop", null, priority = 0, subtasks = ticked) + advanceUntilIdle() + + assertEquals(listOf(1L), offers) + assertFalse(repository.tasks.value.single { it.id == 1L }.isCompleted) + assertTrue(repository.subtasks.value.all { it.isCompleted }) + + // Saving again with nothing newly ticked does not offer twice. + viewModel.showEditTaskDialog(repository.tasks.value.first()) + advanceUntilIdle() + viewModel.editTask(1, "Shop", null, priority = 0, subtasks = viewModel.editingSubtasks.value) + advanceUntilIdle() + assertEquals(listOf(1L), offers) + } + + @Test + fun reorderingAndRemovingKeepTheRowsIdentity() = runTest(dispatcher) { + viewModel.showEditTaskDialog(repository.tasks.value.first()) + advanceUntilIdle() + val loaded = viewModel.editingSubtasks.value!! + val edited = SubtaskDrafts.move(loaded, 1, 0) + SubtaskDraft(key = -1, title = "Bread") + + viewModel.editTask(1, "Shop", null, priority = 0, subtasks = edited.filterNot { it.title == "Milk" }) + advanceUntilIdle() + + val rows = repository.subtasks.value.filter { it.taskId == 1L }.sortedBy { it.position } + assertEquals(listOf("Eggs", "Bread"), rows.map { it.title }) + assertEquals("u11", rows.first().uid) + } + + @Test + fun completingTheTaskLeavesItsSubtasksAsTheyAre() = runTest(dispatcher) { + viewModel.toggleTaskCompletion(1, true) + advanceUntilIdle() + + assertTrue(repository.tasks.value.single { it.id == 1L }.isCompleted) + assertEquals(listOf(true, false), repository.subtasks.value.map { it.isCompleted }) + } + + @Test + fun deleteRemovesTheSubtasksAndUndoBringsThemBack() = runTest(dispatcher) { + advanceUntilIdle() + val task = repository.tasks.value.single { it.id == 1L } + val before = repository.subtasks.value + + viewModel.deleteTask(task) + advanceUntilIdle() + assertTrue(repository.subtasks.value.isEmpty()) + + viewModel.restoreTask(task) + advanceUntilIdle() + assertEquals(before, repository.subtasks.value) + assertEquals(SubtaskProgress(1, done = 1, total = 2), viewModel.uiState.value.subtaskProgress[1L]) + } + + @Test + fun aFailedChecklistSaveIsReportedAndKeepsTheSheetOpen() = runTest(dispatcher) { + val failures = mutableListOf() + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + viewModel.operationFailures.collect(failures::add) + } + advanceUntilIdle() + viewModel.showEditTaskDialog(repository.tasks.value.first()) + advanceUntilIdle() + repository.failSubtasks = true + + viewModel.editTask(1, "Shop", null, priority = 0, subtasks = emptyList()) + advanceUntilIdle() + + assertEquals(listOf(TaskOperationFailure.UPDATE), failures) + assertTrue(viewModel.showAddTaskDialog.value) + assertEquals(2, repository.subtasks.value.size) + } + + @Test + fun aBackgroundDraftSavesTheChecklistToo() = runTest(dispatcher) { + viewModel.showAddTaskDialog() + + viewModel.saveDraftForBackground(TaskDraft("Trip", "", 0, listOf(SubtaskDraft(key = -1, title = "Tickets")))) + advanceUntilIdle() + + val trip = repository.tasks.value.single { it.title == "Trip" } + assertEquals(listOf("Tickets"), repository.subtasks.value.filter { it.taskId == trip.id }.map { it.title }) + } + + private fun TestScope.collectOffers(): List { + val offers = mutableListOf() + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + viewModel.completionOffers.collect(offers::add) + } + return offers + } +} diff --git a/app/src/test/java/com/pasich/encly/testutil/InMemoryVaultDataStore.kt b/app/src/test/java/com/pasich/encly/testutil/InMemoryVaultDataStore.kt index 763ad40..44fbd79 100644 --- a/app/src/test/java/com/pasich/encly/testutil/InMemoryVaultDataStore.kt +++ b/app/src/test/java/com/pasich/encly/testutil/InMemoryVaultDataStore.kt @@ -3,6 +3,7 @@ package com.pasich.encly.testutil import com.pasich.encly.data.backup.VaultDataStore import com.pasich.encly.data.backup.VaultSnapshot import com.pasich.encly.data.model.Note +import com.pasich.encly.data.model.Subtask import com.pasich.encly.data.model.Tag import com.pasich.encly.data.model.Task @@ -14,6 +15,7 @@ internal class InMemoryVaultDataStore : VaultDataStore { val notes = mutableListOf() val tags = mutableListOf() val tasks = mutableListOf() + val subtasks = mutableListOf() /** Makes the n-th insert (1-based, counted across tables) fail, like a constraint error. */ var failOnInsert: Int? = null @@ -24,16 +26,19 @@ internal class InMemoryVaultDataStore : VaultDataStore { notes = notes.map { it.copy() }, tags = tags.map { it.copy() }, tasks = tasks.toList(), + subtasks = subtasks.toList(), ) override suspend fun inTransaction(block: suspend () -> R): R { val saved = Triple(notes.toList(), tags.toList(), tasks.toList()) + val savedSubtasks = subtasks.toList() return try { block() } catch (e: RuntimeException) { restore(notes, saved.first) restore(tags, saved.second) restore(tasks, saved.third) + restore(subtasks, savedSubtasks) throw e } } @@ -42,6 +47,7 @@ internal class InMemoryVaultDataStore : VaultDataStore { notes.clear() tags.clear() tasks.clear() + subtasks.clear() } override suspend fun insertTag(tag: Tag): Long { @@ -62,6 +68,13 @@ internal class InMemoryVaultDataStore : VaultDataStore { return id } + override suspend fun insertSubtask(subtask: Subtask): Long { + check(tasks.any { it.id == subtask.taskId }) { "FOREIGN KEY constraint failed" } + val id = nextInsert(subtask.uid, subtasks.map { it.uid }) + subtasks += subtask.copy(id = id, uid = subtask.uid.ifBlank { "subtask-$id" }) + return id + } + private fun restore(target: MutableList, saved: List) { target.clear() target.addAll(saved) diff --git a/app/src/test/java/com/pasich/encly/testutil/TestTasksRepository.kt b/app/src/test/java/com/pasich/encly/testutil/TestTasksRepository.kt index ec85137..72712c7 100644 --- a/app/src/test/java/com/pasich/encly/testutil/TestTasksRepository.kt +++ b/app/src/test/java/com/pasich/encly/testutil/TestTasksRepository.kt @@ -1,21 +1,30 @@ package com.pasich.encly.testutil +import com.pasich.encly.data.model.Subtask +import com.pasich.encly.data.model.SubtaskProgress import com.pasich.encly.data.model.Task import com.pasich.encly.domain.repository.TasksRepository import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.map -/** Tasks in memory. Each write fails while its `fail*` flag is set. */ -internal class TestTasksRepository(initial: List = emptyList()) : TasksRepository { +/** + * Tasks and sub-tasks in memory; deleting a task deletes its sub-tasks, like the foreign key. + * Each write fails while its `fail*` flag is set. + */ +internal class TestTasksRepository(initial: List = emptyList(), initialSubtasks: List = emptyList()) : + TasksRepository { val tasks = MutableStateFlow(initial) + val subtasks = MutableStateFlow(initialSubtasks) private var nextId = (initial.maxOfOrNull { it.id } ?: 0L) + 1 + private var nextSubtaskId = (initialSubtasks.maxOfOrNull { it.id } ?: 0L) + 1 var failInsert = false var failUpdate = false var failStatus = false var failDelete = false var failClear = false + var failSubtasks = false /** Every (id, isCompleted, completedDate) passed to [updateTaskStatus]. */ val statusUpdates = mutableListOf>() @@ -49,12 +58,39 @@ internal class TestTasksRepository(initial: List = emptyList()) : TasksRep override suspend fun deleteTaskById(id: Long): Result = write(failDelete) { check(tasks.value.any { it.id == id }) tasks.value = tasks.value.filterNot { it.id == id } + subtasks.value = subtasks.value.filterNot { it.taskId == id } } override suspend fun deleteAllCompletedTasks(): Result = write(failClear) { tasks.value = tasks.value.filterNot { it.isCompleted } } + override fun getSubtaskProgress(): Flow> = subtasks.map { list -> + list.groupBy { + it.taskId + }.map { (taskId, rows) -> SubtaskProgress(taskId, rows.count { it.isCompleted }, rows.size) } + } + + override suspend fun getSubtasks(taskId: Long): Result> = + Result.success(subtasks.value.filter { it.taskId == taskId }.sortedBy { it.position }) + + override suspend fun saveSubtasks(taskId: Long, subtasks: List): Result = write(failSubtasks) { + val saved = subtasks.mapIndexed { index, subtask -> + subtask.copy( + id = if (subtask.id > 0L) subtask.id else nextSubtaskId++, + taskId = taskId, + position = index, + uid = subtask.uid.ifBlank { "sub-$nextSubtaskId" }, + ) + } + this.subtasks.value = this.subtasks.value.filterNot { it.taskId == taskId } + saved + } + + override suspend fun restoreTask(task: Task, subtasks: List): Result = write(failInsert) { + tasks.value = tasks.value + task + this.subtasks.value = this.subtasks.value + subtasks.map { it.copy(taskId = task.id) } + } + private inline fun write(fail: Boolean, block: () -> Unit): Result = if (fail) Result.failure(IllegalStateException("write")) else runCatching(block) } diff --git a/app/src/test/java/com/pasich/encly/ui/screens/Fakes.kt b/app/src/test/java/com/pasich/encly/ui/screens/Fakes.kt index f472d31..e87d9b3 100644 --- a/app/src/test/java/com/pasich/encly/ui/screens/Fakes.kt +++ b/app/src/test/java/com/pasich/encly/ui/screens/Fakes.kt @@ -1,5 +1,7 @@ package com.pasich.encly.ui.screens +import com.pasich.encly.data.model.Subtask +import com.pasich.encly.data.model.SubtaskProgress import com.pasich.encly.data.model.Tag import com.pasich.encly.data.model.Task import com.pasich.encly.domain.enums.NoteSortOption @@ -12,6 +14,7 @@ import com.pasich.encly.domain.repository.TagsRepository import com.pasich.encly.domain.repository.TasksRepository import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update @@ -126,4 +129,15 @@ internal class FakeTasksRepository(initial: List = emptyList()) : TasksRep tasks.update { list -> list.filterNot { it.isCompleted } } return Result.success(Unit) } + + override fun getSubtaskProgress(): Flow> = flowOf(emptyList()) + + override suspend fun getSubtasks(taskId: Long): Result> = Result.success(emptyList()) + + override suspend fun saveSubtasks(taskId: Long, subtasks: List): Result = Result.success(Unit) + + override suspend fun restoreTask(task: Task, subtasks: List): Result { + tasks.update { it + task } + return Result.success(Unit) + } } diff --git a/docs/architecture.md b/docs/architecture.md index bab33e7..3ff3c99 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -129,18 +129,19 @@ refuse. A wrong phrase or any modified byte fails GCM (`WRONG_SECRET`); an authe with a newer `schema` is `UNSUPPORTED_VERSION` ("update the app"), and one that does not validate is `INVALID_PAYLOAD`. -The payload (`schema` = 2) is: +The payload (`schema` = 3) is: ```json { - "schema": 2, + "schema": 3, "exportedAt": 1758620000000, "tags": [{ "uid": "…", "name": "…", "visible": true, "position": 0 }], "notes": [{ "uid": "…", "title": "…", "value": "", "description": "…", "date": 0, "dateCreate": 0, "tagUid": "…|null", "isTrash": false }], "tasks": [{ "uid": "…", "title": "…", "description": "…|null", "isCompleted": false, "createdDate": 0, "completedDate": null, "priority": 0, - "categoryTagUid": "…|null", "position": 0 }] + "categoryTagUid": "…|null", "position": 0, + "subtasks": [{ "uid": "…", "title": "…", "isCompleted": false, "position": 0 }] }] } ``` @@ -148,10 +149,13 @@ Unknown keys are rejected rather than ignored, so **every** payload change, even field, must bump `BackupPayload.SCHEMA_VERSION`: the schema is read first, and an older app then reports the file as unsupported instead of damaged. Older schemas stay readable: `BackupPayloadCodec.decode` upgrades them before the strict decode (schema 1 tasks carried a -`reminderDate`, which is dropped). +`reminderDate`, which is dropped; schema 1 and 2 tasks get an empty `subtasks` list). +Sub-task uids are unique across the whole payload. A merge import adds the sub-tasks that a +task already on the device is missing (by uid), after its own ones. -Every note, tag and task row has a unique, never-blank `uid` column (database version 2; -version 3 dropped `tasks.reminderDate`); two triggers in `VaultSchema` fill a blank uid on +Every note, tag, task and sub-task row has a unique, never-blank `uid` column (database +version 2; version 3 dropped `tasks.reminderDate`; version 4 added the `subtasks` table, whose +rows are deleted with their task by an `ON DELETE CASCADE` foreign key); two triggers in `VaultSchema` fill a blank uid on insert and keep it on update, so app code never has to assign one. For implementation details and limits, see [SECURITY.md](../SECURITY.md). From 1c90aaee2241cd76f7de0955ed44be0832345730 Mon Sep 17 00:00:00 2001 From: pasichdev Date: Mon, 5 Oct 2026 00:10:59 +0300 Subject: [PATCH 02/19] feat(security): add an optional wipe PIN that silently erases the vault A second, optional PIN for when someone forces you to open Encly. Typed on the lock screen like the normal PIN, it destroys the vault and opens an empty one, with nothing on screen to show that anything was deleted. It is off by default; Settings -> Security -> Wipe PIN sets or removes it after the real PIN, and warns that a fingerprint can be forced too. Nothing changes for anyone who never sets it. pin.slot, its KDF and the store format stay as they are. Every install gets a pin.wipe.slot: a random decoy until a wipe PIN is set, the same size and version either way, and without a salt of its own (the salt comes from pin.slot), so the store looks the same with and without the feature. One PBKDF2 and Keystore HMAC run per attempt feeds two HKDF keys, and both slots are always tried. Changing the PIN now keeps the salt so the wipe PIN survives; if the device-bound key had to be reset, the wipe PIN is turned off and Settings says so. The erase is a crypto-erase in phases: a new PIN key under the other Keystore alias (A/B), a new DEK and slots, then one atomic store edit that drops the recovery, backup, biometric and lockout entries and sets wipe.pending, then the old database and keys are deleted. A kill at any point is finished on the next start; the user never sees onboarding or a recovery screen. Exported backups are not touched. SECURITY.md describes what this does and does not protect against and replaces "There is no auto-wipe". Refs: #52 --- CHANGELOG.md | 10 + SECURITY.md | 81 ++- .../core/security/AuthenticationManager.kt | 507 ++++++++++++++++-- .../encly/core/security/BiometricManager.kt | 2 +- .../encly/core/security/PinHardwareFactor.kt | 71 ++- .../encly/core/security/SecurityManager.kt | 102 +++- .../encly/core/security/SeedPhraseManager.kt | 4 +- .../pasich/encly/core/security/VaultStore.kt | 3 + .../pasich/encly/data/backup/BackupManager.kt | 8 +- .../encly/presentation/navigation/NavHost.kt | 5 + .../presentation/navigation/NavRoutes.kt | 1 + .../encly/presentation/screen/LockScreen.kt | 13 +- .../screen/settings/SecuritySettingsScreen.kt | 21 + .../screen/settings/WipePinScreen.kt | 365 +++++++++++++ .../presentation/viewmodel/LockViewModel.kt | 6 + .../viewmodel/SecuritySettingsViewModel.kt | 28 + .../main/res/values-de/strings_security.xml | 21 + .../main/res/values-es/strings_security.xml | 21 + .../main/res/values-fr/strings_security.xml | 21 + .../main/res/values-it/strings_security.xml | 21 + .../main/res/values-nl/strings_security.xml | 21 + .../main/res/values-pl/strings_security.xml | 21 + .../main/res/values-pt/strings_security.xml | 21 + .../main/res/values-uk/strings_security.xml | 21 + app/src/main/res/values/strings_security.xml | 21 + .../security/AuthenticationManagerTest.kt | 11 +- .../encly/core/security/PinLockoutTest.kt | 2 +- .../pasich/encly/core/security/WipePinTest.kt | 506 +++++++++++++++++ .../SecuritySettingsViewModelTest.kt | 54 ++ .../pasich/encly/testutil/SecurityFakes.kt | 31 +- .../encly/ui/screens/SecurityScreensTest.kt | 96 ++++ 31 files changed, 2003 insertions(+), 113 deletions(-) create mode 100644 app/src/main/java/com/pasich/encly/presentation/screen/settings/WipePinScreen.kt create mode 100644 app/src/test/java/com/pasich/encly/core/security/WipePinTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index b0f46c0..d26b088 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,12 @@ IzzyOnDroid) and used as the GitHub Release notes. - Encrypted backups carry sub-tasks (backup schema 3). Older backups (schema 1 and 2) still restore, with no sub-tasks. Merge import adds the sub-tasks a task already on the device is missing. +- Wipe PIN (Settings → Security → Wipe PIN): an optional second PIN that, typed on the lock + screen, erases the vault without a visible sign and opens Encly as an empty vault. Nothing in + the app or its storage shows whether one is set. Off by default; nothing changes for vaults + that never set one (a random decoy slot is added at startup without asking for the PIN). + Exported backups are not touched. See SECURITY.md → Wipe PIN for what it does and does not + protect against. ### ⚠️ Compatibility @@ -30,6 +36,10 @@ IzzyOnDroid) and used as the GitHub Release notes. - A backup made with this version is schema 3. Older Encly versions refuse it as "made by a newer version, update the app" instead of restoring it without sub-tasks. +### Changed + +- Changing the PIN keeps the PIN slot's salt. + ## [2.0.1] - 2026-09-25 versionCode 20001. diff --git a/SECURITY.md b/SECURITY.md index 2fce97c..f7816e1 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -46,7 +46,76 @@ DEK (random 256 bit) ── SQLCipher raw key and doubles with each further miss (1, 2, 4 … 32 min, ~1 h …) up to 24 h. It runs on `SystemClock.elapsedRealtime`, so changing the wall clock does not end it; across a reboot the remaining penalty is kept and restarts from boot (a reboot never shortens a lockout). A - failure of the Keystore itself is not counted as a guess. There is no auto-wipe. + failure of the Keystore itself is not counted as a guess. Wrong PINs never erase anything: + the only wipe is the optional **wipe PIN** below, and only when the user set one up. +- Changing the PIN keeps the slot's salt (slots written before keep their own), so a wipe PIN + set earlier keeps working. + +### Wipe PIN + +An optional second PIN (Settings → Security → Wipe PIN; set, replace or turn off, each after +the real PIN). Typed on the lock screen, it erases the vault without a visible sign and opens +Encly as an empty vault, not onboarding. It is meant for coercion on the spot ("open the app +and show me"), not against a forensic examination. + +- **Recognising it without showing it is set.** Every vault with a PIN slot has a + `pin.wipe.slot` of the same length: `version ‖ IV ‖ AES-GCM_wkek(random marker)`, with + `wkek = HKDF(sw ‖ hw, salt, info = "encly/pin/wipe/v3")` from the same KDF run as the PIN + KEK and the AAD (`"encly/pin/wipe-slot/v3"`) bound to the PIN slot's salt. It has no salt of + its own (a second salt equal to or different from the first would give it away). Without a + wipe PIN it holds random bytes, written by a startup migration that needs no PIN and leaves + `pin.slot` byte for byte as it was. The store has the same keys and sizes either way; no + verifier, hash or extra Keystore key exists for it. Only the wipe PIN itself can tell, so + Settings never shows whether one is set. +- **Same cost either way.** Each attempt runs PBKDF2 and the Keystore HMAC once, derives both + KEKs and always tries both AES-GCM opens, without early exit. +- **Rules.** 6 digits and different from the PIN (checked by opening the PIN slot with it). + Changing the PIN to the wipe PIN turns the wipe PIN off (one PIN never opens both slots). A + reset of the PIN's Keystore key (see above) turns it off too, since the slot can never open + again; Settings then says so. +- **Lockout.** It counts as an attempt before the KDF like any PIN and is refused during a + lockout. Once it matched, the erase clears the lockout like a right PIN. Inside an open vault + (PIN re-checks in Settings or before an export) it is just a wrong PIN; it wipes only from the + lock screen. +- **What is destroyed, crypto-erase first.** The PIN's Keystore key alternates between two + aliases (`encly_pin_factor_v3`, the one every vault starts with, and `encly_pin_factor_v3b`); + the active one is recorded in the store (`pin.key_slot`). On the wipe PIN: + 1. with the wipe PIN still in memory: a new key under the other alias, a new random DEK, a new + PIN slot that the wipe PIN opens (same salt, reusing the PBKDF2 result already computed, so + the slow half does not run twice) and a fresh decoy wipe slot; + 2. one atomic store write swaps them in and drops the recovery, backup-key and biometric + slots, the biometric flag and the lockout, and records `wipe.pending`; + 3. the old `database.db` (with `-wal`, `-shm`) is deleted, the empty database is created with + the new DEK and opened, then the old PIN key, the biometric key and the "last export" date + are deleted and `wipe.pending` is dropped. + + A kill at any point ends in either the old vault (before step 2) or an empty one: startup + repeats step 3 while `wipe.pending` is set (every step is a deletion), and the next PIN unlock + creates the empty database if it does not exist yet. If no key can be created under the other + alias, the erase still replaces the slots and deletes the database, only without erasing the + old PIN key. Afterwards the old PIN is a wrong PIN like any other, and the wipe PIN is the PIN + of the empty vault. Theme, sorting, auto-lock and keyboard settings stay. `vault.version` + stays so that the empty vault is a committed vault; it has no recovery phrase until one is + added. +- **Not touched:** backup files already exported. They stay encrypted with the recovery phrase + and are the only way back; the settings page says so. +- **Biometrics** are not switched off when a wipe PIN is set (a fingerprint icon disappearing + from the lock screen would itself be a sign). Someone can force a finger onto the sensor and + open the real vault, so the settings page warns about it and offers to turn biometric unlock + off. +- **Unlock time.** The wipe path adds a Keystore key generation, one HMAC, a store write and + creating the empty database to the same KDF run; the empty vault opens behind the same + unlock animation. +- **Limits.** Against a forensic look at the phone this is partial: leftovers in flash, the + filesystem journal, two snapshots taken before and after, or simply an empty vault on a phone + where Encly was used for years. On a rooted, unlocked phone PIN guessing through the Keystore + (see "Important limitations") finds the wipe PIN as well. Overwriting files on flash is not + reliable, which is why the erase relies on deleting keys first. +- **Downgrade.** Older builds ignore the new store entries and keep opening a vault that never + used the wipe PIN. After a wipe has happened (the PIN key moved to the second alias), an older + build only knows the first alias: the PIN reports a lost key there and cannot unlock. Apart + from this, the same release moves the database to version 4, which older builds cannot open at + all (see CHANGELOG). ### Biometric slot @@ -214,8 +283,8 @@ fileKey = HKDF-SHA256(ikm = backupRoot, salt = <32 random bytes per file>, in settings. - **Erase all data** (Settings → Security, danger zone) is held for 5 seconds, confirmed in a dialog, then re-authenticated with the PIN or a Class 3 biometric. It closes and deletes the - database, every key slot, the lockout state, Encly's Keystore keys (PIN factor and biometric - key) and the vault flags (onboarding state, last export, strict keyboard privacy), and + database, every key slot, the lockout state, Encly's Keystore keys (both PIN factor aliases and + the biometric key) and the vault flags (onboarding state, last export, strict keyboard privacy), and returns to onboarding. There is no undo; only an exported backup brings the data back. @@ -258,8 +327,8 @@ The app requests no `INTERNET` permission and performs no analytics or sync. - Android app sandbox isolates local files from ordinary apps. - `allowBackup=false` is set. - Database/security state is excluded from cloud backup and device transfer. -- The key slots (PIN, recovery, backup-key and biometric envelopes) and the PIN lockout state - live in one app-private file, `no_backup/vault_state_v3.bin`. Every secret in it is already +- The key slots (PIN, wipe-PIN, recovery, backup-key and biometric envelopes) and the PIN + lockout state live in one app-private file, `no_backup/vault_state_v3.bin`. Every secret in it is already an AES-256-GCM envelope, so it needs no further encryption layer and **no Keystore key to be read**: a broken Keystore or Tink keyset can no longer make the vault state unreadable at startup. Every change rewrites the file to a temp file, `fsync`s it and renames it over the @@ -318,6 +387,8 @@ The app requests no `INTERNET` permission and performs no analytics or sync. missing the key lives in the TEE; on devices without hardware-backed Keystore it is software only. - The lockout is enforced by the app, not by the secure hardware. +- The wipe PIN (see "Wipe PIN") defends against being forced to unlock on the spot, not against + a forensic examination or a rooted phone. - The recovery seed restores the local vault (recovery slot) and opens encrypted backups. On a new phone the seed alone recreates nothing: the user also needs a backup file, and only data up to that export is restored. diff --git a/app/src/main/java/com/pasich/encly/core/security/AuthenticationManager.kt b/app/src/main/java/com/pasich/encly/core/security/AuthenticationManager.kt index d5d12a2..5fe147c 100644 --- a/app/src/main/java/com/pasich/encly/core/security/AuthenticationManager.kt +++ b/app/src/main/java/com/pasich/encly/core/security/AuthenticationManager.kt @@ -2,7 +2,6 @@ package com.pasich.encly.core.security import java.security.GeneralSecurityException import java.security.SecureRandom -import javax.crypto.AEADBadTagException import javax.crypto.Cipher import javax.crypto.SecretKeyFactory import javax.crypto.spec.GCMParameterSpec @@ -33,6 +32,13 @@ sealed interface PinUnlock { /** The PIN opened the slot. [dek] belongs to the caller, who must wipe it. */ class Success(val dek: ByteArray) : PinUnlock + /** + * The wipe PIN was typed on the lock screen. The old vault's key slots are already gone + * (phases 1 and 2, see [AuthenticationManager.unlockWithPin]); [dek] opens the new, empty + * vault. The caller owns [dek], must wipe it, and finishes the erase (see [WipeStage]). + */ + class Erased(val dek: ByteArray) : PinUnlock + /** A wrong (or malformed) PIN; the attempt was counted. */ data object WrongPin : PinUnlock @@ -49,26 +55,54 @@ sealed interface PinUnlock { data object Failed : PinUnlock } +/** The outcome of setting a wipe PIN. */ +enum class WipePinChange { + SET, + + /** The wipe PIN is the vault's PIN: it would never wipe anything. */ + SAME_AS_PIN, + FAILED, +} + /** - * Encly v3 PIN unlock slot. + * The part of a wipe-PIN erase that is still to do, kept in the store so that a restart + * finishes it (idempotently) whenever the process died. + */ +enum class WipeStage { + /** The old database may still exist; the new, empty one is created at the next PIN unlock. */ + DATABASE, + + /** The empty database exists; the retired PIN key, the biometric key and the export date are left. */ + CLEANUP, +} + +/** + * Encly v3 PIN unlock slot, and the wipe-PIN slot next to it. * * There is no stored PIN hash. The PIN derives a KEK that must authenticate and decrypt the * random database DEK with AES-256-GCM: * - * hw = HMAC-SHA256_keystore(salt ‖ pin) (device-bound, see PinHardwareFactor) - * sw = PBKDF2-HMAC-SHA256(pin, salt, 600 000) - * kek = HKDF-SHA256(ikm = sw ‖ hw, salt = salt, info = "encly/pin/kek/v3") + * hw = HMAC-SHA256_keystore(salt ‖ pin) (device-bound, see PinHardwareFactor) + * sw = PBKDF2-HMAC-SHA256(pin, salt, 600 000) + * kek = HKDF-SHA256(ikm = sw ‖ hw, salt = salt, info = "encly/pin/kek/v3") + * wkek = HKDF-SHA256(ikm = sw ‖ hw, salt = salt, info = "encly/pin/wipe/v3") * * `hw` means a copied slot is useless off the device: every guess needs this phone's secure * hardware, which also enforces "device unlocked" on API 28+. PBKDF2 is kept at full cost as * defence in depth for the case where the Keystore key itself were ever extracted. * + * The wipe slot (`pin.wipe.slot`) is `version ‖ iv ‖ AES-GCM_wkek(random marker)`: no salt of + * its own (a second salt equal to or different from the PIN slot's would tell whether it is + * real), its AAD bound to the PIN slot's salt. Without a wipe PIN it holds random bytes of the + * same length, so the store has the same keys and sizes either way and only the wipe PIN itself + * can tell. Every attempt runs the KDF once and tries both slots, with no early exit. + * * Failed attempts are counted durably *before* the KDF runs, under one lock, so killing the * app mid-check or racing two checks never gets a free guess. The lockout runs on * [LockoutClock.elapsedRealtime], not the wall clock, and escalates up to [MAX_LOCKOUT_MS]. */ @Singleton -@Suppress("TooManyFunctions") // The complete PIN slot and lockout lifecycle in one place. +@Suppress("TooManyFunctions") // The complete PIN slot, wipe slot and lockout lifecycle in one place. class AuthenticationManager @Inject constructor( private val store: VaultStore, private val factor: PinHardwareFactor, @@ -76,50 +110,81 @@ class AuthenticationManager @Inject constructor( ) { private val attemptLock = Any() - /** Creates (or replaces) the PIN slot around [dek]. [pin] stays the caller's to wipe. */ + /** + * Creates (or replaces) the PIN slot around [dek]. [pin] stays the caller's to wipe. + * + * A replaced slot keeps its salt, so a wipe PIN set earlier keeps working. The wipe slot is + * replaced by a decoy (the wipe PIN turned off) when it can no longer open, because the + * PIN key had to be reset (Settings then says so), or must not, because [pin] is the wipe + * PIN itself: one PIN never opens both slots. + */ @Suppress("ReturnCount") // Validation gates fail closed before any key is touched. fun configurePin(pin: CharArray, dek: ByteArray): Boolean { if (!isWellFormed(pin) || dek.size != DEK_LENGTH) return false - val salt = ByteArray(PIN_SALT_SIZE).also { SecureRandom().nextBytes(it) } - val kek = try { - factor.ensureKey() + val keySlot = activeKeySlot() + val keptSalt = currentSalt() + val salt = keptSalt ?: ByteArray(PIN_SALT_SIZE).also { SecureRandom().nextBytes(it) } + var keyReset = false + val keys = try { + factor.ensureKey(keySlot) try { - derivePinKek(pin, salt) + derivePinKeys(pin, salt, keySlot) } catch (e: PinFactorException) { // A key the system invalidated can be replaced: the PIN it served is lost anyway. if (!e.lost) throw e - factor.reset() - derivePinKek(pin, salt) + factor.reset(keySlot) + keyReset = true + derivePinKeys(pin, salt, keySlot) } } catch (_: PinFactorException) { SensitiveDataCleaner.clear(salt) return false } return try { - val slot = sealSlot(dek, kek, salt) - try { - store.edit { - putBytes(PIN_SLOT_KEY, slot) - putInt(AUTH_TYPE_KEY, AuthType.PIN.ordinal) - removePrefix(LOCKOUT_PREFIX) - } - } finally { - SensitiveDataCleaner.clear(slot) + val change = when { + keyReset && keptSalt != null -> WipeSlotChange.TURN_OFF_AND_TELL + keyReset || keptSalt == null -> WipeSlotChange.TURN_OFF + else -> wipeSlotChangeFor(keys.wipeKek, salt) } + writePinSlot(dek, keys.kek, salt, keySlot, change) } catch (_: GeneralSecurityException) { false } finally { SensitiveDataCleaner.clear(salt) - SensitiveDataCleaner.clear(kek) + keys.wipe() } } /** - * One PIN attempt. [pin] stays the caller's to wipe. On [PinUnlock.Success] the caller - * owns the DEK and must wipe it. + * One PIN attempt. [pin] stays the caller's to wipe. On [PinUnlock.Success] and + * [PinUnlock.Erased] the caller owns the DEK and must wipe it. + * + * The KDF runs once and both slots are always tried, so the PIN, the wipe PIN and a wrong + * PIN cost the same up to here. The wipe PIN is counted before the KDF like any attempt and + * is refused during a lockout; once it matched, the erase drops the lockout like a right + * PIN does. In a PIN check inside the open vault ([verifyPinAuth]) it is a wrong PIN. + * + * On the wipe PIN, before returning: phase 1 makes a new key in the other Keystore slot + * ([PinKeySlot]), a new DEK, a new PIN slot that the wipe PIN opens (same salt, and the + * PBKDF2 result already computed, so the slow half does not run twice) and a fresh decoy + * wipe slot; phase 2 swaps them in with one atomic store edit that also drops the recovery, + * backup-key and biometric slots and the lockout, and records [WipeStage.DATABASE]. + */ + fun unlockWithPin(pin: CharArray): PinUnlock = attempt(pin, allowWipe = true) + + /** + * Whether [pin] opens the slot; counts like an unlock attempt. [pin] stays the caller's. + * The wipe PIN only wipes from the lock screen: here it is a wrong PIN. */ + fun verifyPinAuth(pin: CharArray): Boolean { + val result = attempt(pin, allowWipe = false) + if (result is PinUnlock.Success) SensitiveDataCleaner.clear(result.dek) + return result is PinUnlock.Success + } + + /** [unlockWithPin]; with [allowWipe] false a wipe-PIN match is a wrong PIN. */ @Suppress("ReturnCount") // Fail-closed gates: lockout, missing slot, unrecorded attempt. - fun unlockWithPin(pin: CharArray): PinUnlock = synchronized(attemptLock) { + private fun attempt(pin: CharArray, allowWipe: Boolean): PinUnlock = synchronized(attemptLock) { if (remainingLockoutMillis() > 0) return PinUnlock.LockedOut val slot = store.getBytes(PIN_SLOT_KEY) ?: return PinUnlock.WrongPin try { @@ -130,23 +195,17 @@ class AuthenticationManager @Inject constructor( val parsed = parseSlot(slot) ?: return PinUnlock.WrongPin try { - val kek = try { - derivePinKek(pin, parsed.salt) + val keys = try { + derivePinKeys(pin, parsed.salt, activeKeySlot()) } catch (e: PinFactorException) { // Not a guess: the hardware never answered. Give the attempt back. refundAttempt(previousFailures) return if (e.lost) PinUnlock.KeyLost else PinUnlock.Failed } try { - val dek = openSlot(parsed, kek) - store.edit { removePrefix(LOCKOUT_PREFIX) } - PinUnlock.Success(dek) - } catch (_: AEADBadTagException) { - PinUnlock.WrongPin - } catch (_: GeneralSecurityException) { - PinUnlock.WrongPin + checkBothSlots(pin, parsed, keys, allowWipe) } finally { - SensitiveDataCleaner.clear(kek) + keys.wipe() } } finally { parsed.wipe() @@ -156,13 +215,100 @@ class AuthenticationManager @Inject constructor( } } - /** Whether [pin] opens the slot; counts like an unlock attempt. [pin] stays the caller's. */ - fun verifyPinAuth(pin: CharArray): Boolean { - val result = unlockWithPin(pin) - if (result is PinUnlock.Success) SensitiveDataCleaner.clear(result.dek) - return result is PinUnlock.Success + /** + * Makes [wipePin] the wipe PIN, replacing any earlier one (whether there was one cannot be + * known: a real wipe slot and the decoy look the same). [wipePin] stays the caller's to + * wipe. Refused when it is the PIN itself. Runs the KDF once, like an unlock, and is not + * counted as an attempt: the caller has the vault open and has checked the PIN. + */ + @Suppress("ReturnCount") // Fail-closed gates before the slot is written. + fun configureWipePin(wipePin: CharArray): WipePinChange = synchronized(attemptLock) { + if (!isWellFormed(wipePin)) return WipePinChange.FAILED + val slot = store.getBytes(PIN_SLOT_KEY) ?: return WipePinChange.FAILED + val parsed = try { + parseSlot(slot) + } finally { + SensitiveDataCleaner.clear(slot) + } ?: return WipePinChange.FAILED + try { + val keys = try { + derivePinKeys(wipePin, parsed.salt, activeKeySlot()) + } catch (_: PinFactorException) { + return WipePinChange.FAILED + } + try { + // A wipe PIN that opens the PIN slot would only ever unlock. + val dek = openSlotOrNull(parsed, keys.kek) + if (dek != null) { + SensitiveDataCleaner.clear(dek) + return WipePinChange.SAME_AS_PIN + } + writeWipeSlot(keys.wipeKek, parsed.salt) + } finally { + keys.wipe() + } + } finally { + parsed.wipe() + } + } + + /** Turns the wipe PIN off: the wipe slot becomes a fresh decoy, as on a vault that never had one. */ + fun removeWipePin(): Boolean { + val decoy = decoyWipeSlot() + return try { + store.edit { + putBytes(WIPE_SLOT_KEY, decoy) + remove(WIPE_NOTICE_KEY) + } + } finally { + SensitiveDataCleaner.clear(decoy) + } } + /** + * True after the PIN key had to be reset (see [configurePin]): any wipe PIN was turned off + * and Settings asks to set it again. Cleared by setting or removing the wipe PIN. + */ + fun wipePinTurnedOff(): Boolean = store.getBoolean(WIPE_NOTICE_KEY, false) + + /** + * Startup migration for vaults made before the wipe PIN: adds the decoy wipe slot (random + * bytes, so no PIN is needed) and records key slot A. `pin.slot` is left byte for byte as + * it is; a store without a PIN slot, or with both entries already, is not written at all. + */ + fun ensureWipeSlot(): Boolean { + val hasPinSlot = hasPinSlot() + val needsDecoy = hasPinSlot && !store.contains(WIPE_SLOT_KEY) + val needsKeySlot = hasPinSlot && !store.contains(KEY_SLOT_KEY) + if (!needsDecoy && !needsKeySlot) return true + val decoy = decoyWipeSlot() + return try { + store.edit { + if (needsDecoy) putBytes(WIPE_SLOT_KEY, decoy) + if (needsKeySlot) putInt(KEY_SLOT_KEY, PinKeySlot.A.ordinal) + } + } finally { + SensitiveDataCleaner.clear(decoy) + } + } + + /** The unfinished part of a wipe-PIN erase, or null when there is none. */ + fun pendingWipe(): WipeStage? = if (store.contains(WIPE_PENDING_KEY)) { + // An unknown value redoes the most: the database step. + WipeStage.entries.getOrNull(store.getInt(WIPE_PENDING_KEY, -1)) ?: WipeStage.DATABASE + } else { + null + } + + /** The empty database of an erased vault exists; only the cleanup is left. */ + fun markWipeDatabaseCreated(): Boolean = store.edit { putInt(WIPE_PENDING_KEY, WipeStage.CLEANUP.ordinal) } + + /** Deletes the PIN key the erase moved away from (nothing when there is none). */ + fun deleteRetiredPinKey() = factor.delete(activeKeySlot().other) + + /** The wipe-PIN erase is complete. */ + fun clearPendingWipe(): Boolean = store.edit { remove(WIPE_PENDING_KEY) } + fun hasPinSlot(): Boolean = store.contains(PIN_SLOT_KEY) fun getAuthType(): AuthType { @@ -218,14 +364,165 @@ class AuthenticationManager @Inject constructor( return (penalty - (now - anchor)).coerceAtLeast(0L) } - /** Deletes the PIN slot, the lockout and the device-bound PIN key. */ + /** Deletes the PIN and wipe slots, the lockout and the device-bound PIN keys. */ fun wipe() { store.edit { removePrefix(PIN_PREFIX) removePrefix(LOCKOUT_PREFIX) removePrefix(AUTH_PREFIX) + remove(WIPE_PENDING_KEY) + } + PinKeySlot.entries.forEach(factor::delete) + } + + /** Opens whichever slot [keys] open; see [unlockWithPin]. */ + private fun checkBothSlots(pin: CharArray, parsed: ParsedSlot, keys: PinKeys, allowWipe: Boolean): PinUnlock { + val dek = openSlotOrNull(parsed, keys.kek) + // Tried even when the PIN slot already opened: no early exit. + val wipeSlot = store.getBytes(WIPE_SLOT_KEY) + val wipeMatch = wipeSlot != null && + try { + opensWipeSlot(wipeSlot, keys.wipeKek, parsed.salt) + } finally { + SensitiveDataCleaner.clear(wipeSlot) + } + return when { + dek != null -> { + store.edit { removePrefix(LOCKOUT_PREFIX) } + PinUnlock.Success(dek) + } + + wipeMatch && allowWipe -> eraseVault(pin, parsed.salt, keys) + + else -> PinUnlock.WrongPin + } + } + + /** + * Phases 1 and 2 of the wipe-PIN erase (see [unlockWithPin]). If no key can be made in the + * other Keystore slot, the new PIN slot uses the current key: the old slots are still + * dropped and the old database still deleted, only the crypto-erase of the old PIN key is + * lost. A store that cannot be written changes nothing and reports [PinUnlock.Failed]. + */ + private fun eraseVault(pin: CharArray, salt: ByteArray, keys: PinKeys): PinUnlock { + val keySlot = freshKeySlot(keys.keySlot) + val kek = try { + val hardware = hardwareHalf(pin, salt, keySlot) + try { + expand(keys.stretched, hardware, salt, KEK_INFO) + } finally { + SensitiveDataCleaner.clear(hardware) + } + } catch (_: PinFactorException) { + return PinUnlock.Failed + } + val dek = ByteArray(DEK_LENGTH).also { SecureRandom().nextBytes(it) } + val decoy = decoyWipeSlot() + return try { + val slot = sealSlot(dek, kek, salt) + val erased = try { + store.edit { + putBytes(PIN_SLOT_KEY, slot) + putBytes(WIPE_SLOT_KEY, decoy) + putInt(KEY_SLOT_KEY, keySlot.ordinal) + putInt(WIPE_PENDING_KEY, WipeStage.DATABASE.ordinal) + remove(WIPE_NOTICE_KEY) + remove(BIOMETRIC_ENABLED_KEY) + removePrefix(LOCKOUT_PREFIX) + ERASED_PREFIXES.forEach { removePrefix(it) } + } + } finally { + SensitiveDataCleaner.clear(slot) + } + if (erased) PinUnlock.Erased(dek.copyOf()) else PinUnlock.Failed + } catch (_: GeneralSecurityException) { + PinUnlock.Failed + } finally { + SensitiveDataCleaner.clear(dek) + SensitiveDataCleaner.clear(kek) + SensitiveDataCleaner.clear(decoy) + } + } + + /** + * For a new PIN whose wipe KEK is [wipeKek] (same salt, same key): the wipe slot stays only + * if it exists and the new PIN does not open it, i.e. is not the wipe PIN. + */ + private fun wipeSlotChangeFor(wipeKek: ByteArray, salt: ByteArray): WipeSlotChange { + val wipeSlot = store.getBytes(WIPE_SLOT_KEY) ?: return WipeSlotChange.TURN_OFF + return try { + if (opensWipeSlot(wipeSlot, wipeKek, salt)) WipeSlotChange.TURN_OFF else WipeSlotChange.KEEP + } finally { + SensitiveDataCleaner.clear(wipeSlot) + } + } + + /** A new key in the slot other than [current]; [current] itself when none can be made. */ + private fun freshKeySlot(current: PinKeySlot): PinKeySlot = try { + factor.reset(current.other) + current.other + } catch (_: PinFactorException) { + current + } + + private fun writePinSlot( + dek: ByteArray, + kek: ByteArray, + salt: ByteArray, + keySlot: PinKeySlot, + change: WipeSlotChange, + ): Boolean { + val slot = sealSlot(dek, kek, salt) + val decoy = if (change == WipeSlotChange.KEEP) null else decoyWipeSlot() + return try { + store.edit { + putBytes(PIN_SLOT_KEY, slot) + decoy?.let { putBytes(WIPE_SLOT_KEY, it) } + putInt(KEY_SLOT_KEY, keySlot.ordinal) + putInt(AUTH_TYPE_KEY, AuthType.PIN.ordinal) + if (change == WipeSlotChange.TURN_OFF_AND_TELL) putBoolean(WIPE_NOTICE_KEY, true) + removePrefix(LOCKOUT_PREFIX) + } + } finally { + SensitiveDataCleaner.clear(slot) + decoy?.let(SensitiveDataCleaner::clear) + } + } + + private fun writeWipeSlot(wipeKek: ByteArray, salt: ByteArray): WipePinChange { + val wipeSlot = try { + sealWipeSlot(wipeKek, salt) + } catch (_: GeneralSecurityException) { + return WipePinChange.FAILED + } + return try { + val written = store.edit { + putBytes(WIPE_SLOT_KEY, wipeSlot) + remove(WIPE_NOTICE_KEY) + } + if (written) WipePinChange.SET else WipePinChange.FAILED + } finally { + SensitiveDataCleaner.clear(wipeSlot) + } + } + + /** The key slot the PIN slot is sealed with: A unless an erase moved it. */ + private fun activeKeySlot(): PinKeySlot = + PinKeySlot.entries.getOrNull(store.getInt(KEY_SLOT_KEY, PinKeySlot.A.ordinal)) ?: PinKeySlot.A + + /** The salt of the current PIN slot, kept across PIN changes; null without a readable slot. */ + private fun currentSalt(): ByteArray? { + val slot = store.getBytes(PIN_SLOT_KEY) ?: return null + val parsed = try { + parseSlot(slot) + } finally { + SensitiveDataCleaner.clear(slot) + } + return parsed?.let { + val salt = it.salt.copyOf() + it.wipe() + salt } - factor.delete() } private fun chargeAttempt(failures: Int): Boolean = store.edit { @@ -249,24 +546,45 @@ class AuthenticationManager @Inject constructor( private fun isWellFormed(pin: CharArray): Boolean = pin.size == PIN_LENGTH && pin.all { it in '0'..'9' } - /** [PinFactorException] leaves nothing behind; the result is the caller's to wipe. */ - private fun derivePinKek(pin: CharArray, salt: ByteArray): ByteArray { + /** + * One KDF run for both slots. [PinFactorException] leaves nothing behind; the result is + * the caller's to wipe. + */ + private fun derivePinKeys(pin: CharArray, salt: ByteArray, keySlot: PinKeySlot): PinKeys { + // The hardware half first: it is cheap, and a lost key fails before the PBKDF2 cost. + val hardware = hardwareHalf(pin, salt, keySlot) + val stretched = pbkdf2(pin, salt) + val ikm = stretched + hardware + return try { + PinKeys( + keySlot = keySlot, + stretched = stretched, + kek = Hkdf.sha256(ikm = ikm, salt = salt, info = KEK_INFO, length = DEK_LENGTH), + wipeKek = Hkdf.sha256(ikm = ikm, salt = salt, info = WIPE_INFO, length = DEK_LENGTH), + ) + } finally { + SensitiveDataCleaner.clear(hardware) + SensitiveDataCleaner.clear(ikm) + } + } + + /** HMAC of `salt ‖ pin` under the key in [keySlot]; the caller wipes the result. */ + private fun hardwareHalf(pin: CharArray, salt: ByteArray, keySlot: PinKeySlot): ByteArray { val macInput = ByteArray(salt.size + pin.size) System.arraycopy(salt, 0, macInput, 0, salt.size) pin.forEachIndexed { index, c -> macInput[salt.size + index] = c.code.toByte() } - // The hardware half first: it is cheap, and a lost key fails before the PBKDF2 cost. - val hardware = try { - factor.mac(macInput) + return try { + factor.mac(keySlot, macInput) } finally { SensitiveDataCleaner.clear(macInput) } - val stretched = pbkdf2(pin, salt) + } + + private fun expand(stretched: ByteArray, hardware: ByteArray, salt: ByteArray, info: ByteArray): ByteArray { val ikm = stretched + hardware return try { - Hkdf.sha256(ikm = ikm, salt = salt, info = KEK_INFO, length = DEK_LENGTH) + Hkdf.sha256(ikm = ikm, salt = salt, info = info, length = DEK_LENGTH) } finally { - SensitiveDataCleaner.clear(hardware) - SensitiveDataCleaner.clear(stretched) SensitiveDataCleaner.clear(ikm) } } @@ -304,15 +622,64 @@ class AuthenticationManager @Inject constructor( return ParsedSlot(salt, iv, slot.copyOfRange(offset, slot.size)) } - private fun openSlot(slot: ParsedSlot, kek: ByteArray): ByteArray { + /** The DEK in [slot], or null when [kek] does not open it. The caller wipes the result. */ + private fun openSlotOrNull(slot: ParsedSlot, kek: ByteArray): ByteArray? = try { val cipher = Cipher.getInstance(AES_GCM) cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(kek, "AES"), GCMParameterSpec(GCM_TAG_LENGTH, slot.iv)) cipher.updateAAD(aad(slot.salt)) - return cipher.doFinal(slot.ciphertext) + cipher.doFinal(slot.ciphertext) + } catch (_: GeneralSecurityException) { + // AEADBadTagException included: a wrong PIN. + null } private fun aad(salt: ByteArray): ByteArray = PIN_AAD + byteArrayOf(SLOT_VERSION) + salt + /** `version ‖ iv ‖ AES-GCM(random marker)`, bound to the PIN slot's [salt] as AAD. */ + private fun sealWipeSlot(wipeKek: ByteArray, salt: ByteArray): ByteArray { + val marker = ByteArray(WIPE_MARKER_LENGTH).also { SecureRandom().nextBytes(it) } + val cipher = Cipher.getInstance(AES_GCM) + cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(wipeKek, "AES")) + cipher.updateAAD(wipeAad(salt)) + val encrypted = try { + cipher.doFinal(marker) + } finally { + SensitiveDataCleaner.clear(marker) + } + return try { + byteArrayOf(SLOT_VERSION) + cipher.iv + encrypted + } finally { + SensitiveDataCleaner.clear(encrypted) + } + } + + /** Whether [wipeKek] authenticates [wipeSlot]; a decoy never does. */ + private fun opensWipeSlot(wipeSlot: ByteArray, wipeKek: ByteArray, salt: ByteArray): Boolean { + if (wipeSlot.size != WIPE_SLOT_LENGTH || wipeSlot[0] != SLOT_VERSION) return false + val iv = wipeSlot.copyOfRange(1, 1 + IV_LENGTH) + val ciphertext = wipeSlot.copyOfRange(1 + IV_LENGTH, wipeSlot.size) + return try { + val cipher = Cipher.getInstance(AES_GCM) + cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(wipeKek, "AES"), GCMParameterSpec(GCM_TAG_LENGTH, iv)) + cipher.updateAAD(wipeAad(salt)) + SensitiveDataCleaner.clear(cipher.doFinal(ciphertext)) + true + } catch (_: GeneralSecurityException) { + false + } finally { + SensitiveDataCleaner.clear(iv) + SensitiveDataCleaner.clear(ciphertext) + } + } + + private fun wipeAad(salt: ByteArray): ByteArray = WIPE_AAD + byteArrayOf(SLOT_VERSION) + salt + + /** Random bytes shaped like a wipe slot (the same version byte and length): no PIN opens it. */ + private fun decoyWipeSlot(): ByteArray = ByteArray(WIPE_SLOT_LENGTH).also { + SecureRandom().nextBytes(it) + it[0] = SLOT_VERSION + } + private class ParsedSlot(val salt: ByteArray, val iv: ByteArray, val ciphertext: ByteArray) { fun wipe() { SensitiveDataCleaner.clear(salt) @@ -321,9 +688,30 @@ class AuthenticationManager @Inject constructor( } } + /** Both KEKs of one KDF run, and its PBKDF2 half (reused if the vault is erased). */ + private class PinKeys( + val keySlot: PinKeySlot, + val stretched: ByteArray, + val kek: ByteArray, + val wipeKek: ByteArray, + ) { + fun wipe() { + SensitiveDataCleaner.clear(stretched) + SensitiveDataCleaner.clear(kek) + SensitiveDataCleaner.clear(wipeKek) + } + } + + /** What a new PIN slot does to the wipe slot (see [configurePin]). */ + private enum class WipeSlotChange { KEEP, TURN_OFF, TURN_OFF_AND_TELL } + companion object { private const val PIN_PREFIX = "pin." private const val PIN_SLOT_KEY = "pin.slot" + private const val WIPE_SLOT_KEY = "pin.wipe.slot" + private const val WIPE_NOTICE_KEY = "pin.wipe.notice" + private const val KEY_SLOT_KEY = "pin.key_slot" + private const val WIPE_PENDING_KEY = "wipe.pending" private const val AUTH_PREFIX = "auth." private const val AUTH_TYPE_KEY = "auth.type" private const val BIOMETRIC_ENABLED_KEY = "auth.biometric_enabled" @@ -346,10 +734,21 @@ class AuthenticationManager @Inject constructor( private const val DEK_LENGTH = 32 private const val SLOT_VERSION: Byte = 3 private const val SLOT_LENGTH = 1 + PIN_SALT_SIZE + IV_LENGTH + DEK_LENGTH + GCM_TAG_BYTES + private const val WIPE_MARKER_LENGTH = 32 + private const val WIPE_SLOT_LENGTH = 1 + IV_LENGTH + WIPE_MARKER_LENGTH + GCM_TAG_BYTES private const val AES_GCM = "AES/GCM/NoPadding" private val PIN_AAD = "encly/pin/slot/v3".toByteArray(Charsets.UTF_8) private val KEK_INFO = "encly/pin/kek/v3".toByteArray(Charsets.UTF_8) + private val WIPE_AAD = "encly/pin/wipe-slot/v3".toByteArray(Charsets.UTF_8) + private val WIPE_INFO = "encly/pin/wipe/v3".toByteArray(Charsets.UTF_8) + + /** Every other way back to an erased vault's DEK: recovery, backup-key and biometric slots. */ + private val ERASED_PREFIXES = listOf( + SeedPhraseManager.RECOVERY_PREFIX, + SeedPhraseManager.BACKUP_PREFIX, + BiometricManager.SLOT_PREFIX, + ) /** * The lockout after [failures] consecutive misses: none before [MAX_ATTEMPTS], then diff --git a/app/src/main/java/com/pasich/encly/core/security/BiometricManager.kt b/app/src/main/java/com/pasich/encly/core/security/BiometricManager.kt index b6d40ca..334af52 100644 --- a/app/src/main/java/com/pasich/encly/core/security/BiometricManager.kt +++ b/app/src/main/java/com/pasich/encly/core/security/BiometricManager.kt @@ -57,7 +57,7 @@ class BiometricManager @Inject constructor( companion object { private const val KEYSTORE_PROVIDER = "AndroidKeyStore" private const val KEY_ALIAS = "encly_biometric_wrap_v2" - private const val SLOT_PREFIX = "bio." + internal const val SLOT_PREFIX = "bio." private const val SLOT_KEY = "bio.slot" private const val GCM_TAG_LENGTH = 128 private const val IV_LENGTH = 12 diff --git a/app/src/main/java/com/pasich/encly/core/security/PinHardwareFactor.kt b/app/src/main/java/com/pasich/encly/core/security/PinHardwareFactor.kt index c8f4c61..61a82b9 100644 --- a/app/src/main/java/com/pasich/encly/core/security/PinHardwareFactor.kt +++ b/app/src/main/java/com/pasich/encly/core/security/PinHardwareFactor.kt @@ -14,6 +14,20 @@ import javax.crypto.SecretKey import javax.inject.Inject import javax.inject.Singleton +/** + * The two places the device-bound PIN key can live. [A] is the one every vault starts with + * (and the only one older builds know); a wipe-PIN unlock moves the PIN slot to the other one + * and deletes the old key, so the erased slots can never be opened again (crypto-erase). The + * vault records which one is active. + */ +enum class PinKeySlot { + A, + B, + ; + + val other: PinKeySlot get() = if (this == A) B else A +} + /** * The device-bound half of the PIN key: an HMAC-SHA256 whose key never leaves this device's * secure hardware. Mixed into the PIN KEK, it makes every PIN guess run here, on the @@ -21,20 +35,20 @@ import javax.inject.Singleton * can replace the Keystore. */ interface PinHardwareFactor { - /** Creates the key unless it already exists. */ + /** Creates the key in [slot] unless it already exists. */ @Throws(PinFactorException::class) - fun ensureKey() + fun ensureKey(slot: PinKeySlot) - /** Replaces the key with a new one; the old PIN slot can no longer be opened. */ + /** Replaces the key in [slot] with a new one; slots sealed with the old one can no longer be opened. */ @Throws(PinFactorException::class) - fun reset() + fun reset(slot: PinKeySlot) - /** HMAC-SHA256 of [data] under the device-bound key. The caller wipes the result. */ + /** HMAC-SHA256 of [data] under the key in [slot]. The caller wipes the result. */ @Throws(PinFactorException::class) - fun mac(data: ByteArray): ByteArray + fun mac(slot: PinKeySlot, data: ByteArray): ByteArray - /** Deletes the key, if any. Never throws. */ - fun delete() + /** Deletes the key in [slot], if any. Never throws. */ + fun delete(slot: PinKeySlot) } /** @@ -56,21 +70,21 @@ class PinFactorException(val lost: Boolean, cause: Throwable? = null) : Exceptio class KeystorePinFactor @Inject constructor() : PinHardwareFactor { private val keyStore by lazy { KeyStore.getInstance(KEYSTORE_PROVIDER).apply { load(null) } } - override fun ensureKey() { + override fun ensureKey(slot: PinKeySlot) { val exists = try { - keyStore.containsAlias(KEY_ALIAS) + keyStore.containsAlias(alias(slot)) } catch (e: GeneralSecurityException) { throw PinFactorException(lost = false, cause = e) } catch (e: RuntimeException) { throw PinFactorException(lost = false, cause = e) } - if (!exists) reset() + if (!exists) reset(slot) } - override fun reset() { - delete() + override fun reset(slot: PinKeySlot) { + delete(slot) try { - generate() + generate(slot) } catch (e: GeneralSecurityException) { throw PinFactorException(lost = false, cause = e) } catch (e: RuntimeException) { @@ -78,9 +92,9 @@ class KeystorePinFactor @Inject constructor() : PinHardwareFactor { } } - override fun mac(data: ByteArray): ByteArray { + override fun mac(slot: PinKeySlot, data: ByteArray): ByteArray { val key = try { - keyStore.getKey(KEY_ALIAS, null) as? SecretKey + keyStore.getKey(alias(slot), null) as? SecretKey } catch (e: UnrecoverableKeyException) { throw PinFactorException(lost = true, cause = e) } catch (e: GeneralSecurityException) { @@ -101,9 +115,10 @@ class KeystorePinFactor @Inject constructor() : PinHardwareFactor { } } - override fun delete() { + override fun delete(slot: PinKeySlot) { + val alias = alias(slot) try { - if (keyStore.containsAlias(KEY_ALIAS)) keyStore.deleteEntry(KEY_ALIAS) + if (keyStore.containsAlias(alias)) keyStore.deleteEntry(alias) } catch (e: GeneralSecurityException) { AppLogger.w(TAG, "PIN factor key could not be deleted", e) } catch (e: RuntimeException) { @@ -115,7 +130,7 @@ class KeystorePinFactor @Inject constructor() : PinHardwareFactor { * StrongBox first; a device without one (or whose StrongBox lacks HMAC) falls back to the * TEE. The unlocked-device requirement is dropped only if the platform rejects it outright. */ - private fun generate() { + private fun generate(slot: PinKeySlot) { val attempts = buildList { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) add(Options(strongBox = true, unlockedOnly = true)) add(Options(strongBox = false, unlockedOnly = true)) @@ -125,7 +140,7 @@ class KeystorePinFactor @Inject constructor() : PinHardwareFactor { for (options in attempts) { try { KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_HMAC_SHA256, KEYSTORE_PROVIDER).run { - init(spec(options)) + init(spec(alias(slot), options)) generateKey() } return @@ -136,13 +151,13 @@ class KeystorePinFactor @Inject constructor() : PinHardwareFactor { // ProviderExceptions. last = e } - delete() + delete(slot) } throw last ?: error("no key generated") } - private fun spec(options: Options): KeyGenParameterSpec { - val builder = KeyGenParameterSpec.Builder(KEY_ALIAS, KeyProperties.PURPOSE_SIGN) + private fun spec(alias: String, options: Options): KeyGenParameterSpec { + val builder = KeyGenParameterSpec.Builder(alias, KeyProperties.PURPOSE_SIGN) .setKeySize(HMAC_KEY_BITS) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { builder.setIsStrongBoxBacked(options.strongBox) @@ -156,7 +171,15 @@ class KeystorePinFactor @Inject constructor() : PinHardwareFactor { private companion object { const val TAG = "KeystorePinFactor" const val KEYSTORE_PROVIDER = "AndroidKeyStore" - const val KEY_ALIAS = "encly_pin_factor_v3" + + /** Slot A keeps the alias every vault has used since v3, so existing installs are untouched. */ + const val KEY_ALIAS_A = "encly_pin_factor_v3" + const val KEY_ALIAS_B = "encly_pin_factor_v3b" const val HMAC_KEY_BITS = 256 + + fun alias(slot: PinKeySlot): String = when (slot) { + PinKeySlot.A -> KEY_ALIAS_A + PinKeySlot.B -> KEY_ALIAS_B + } } } diff --git a/app/src/main/java/com/pasich/encly/core/security/SecurityManager.kt b/app/src/main/java/com/pasich/encly/core/security/SecurityManager.kt index 01caff3..e73b688 100644 --- a/app/src/main/java/com/pasich/encly/core/security/SecurityManager.kt +++ b/app/src/main/java/com/pasich/encly/core/security/SecurityManager.kt @@ -3,6 +3,7 @@ package com.pasich.encly.core.security import android.content.SharedPreferences import androidx.fragment.app.FragmentActivity import com.pasich.encly.core.AppLogger +import com.pasich.encly.data.backup.BackupManager import com.pasich.encly.data.database.SecureDatabaseManager import javax.inject.Inject import javax.inject.Singleton @@ -68,6 +69,11 @@ class SecurityManager @Inject constructor( @Volatile private var sessionUnlockedWithRecovery = false + // True while the open session is the empty vault a wipe-PIN unlock just made. Nothing of + // the erased vault (such as the note that was open when the app re-locked) may reappear. + @Volatile + private var sessionIsErasedVault = false + var securityStatus = InitialStatus.NO /** @@ -89,8 +95,15 @@ class SecurityManager @Inject constructor( private fun initializeSecurity(): InitialStatus { if (vaultStore.isCorrupt()) return InitialStatus.LOSS_CRYPTO if (!isOnboardingShown()) return uncommittedVaultStatus() + // Vaults from before the wipe PIN get their decoy wipe slot; no PIN needed, nothing shown. + authenticationManager.ensureWipeSlot() + // A wipe-PIN erase the process died in is finished before anything else looks at the vault. + completePendingWipe() if (!seedPhraseManager.verificationKeyData()) return InitialStatus.LOSS_CRYPTO - if (!secureDatabaseManager.hasEncryptedDatabase()) return InitialStatus.LOSS_DATABASE + // An erase that has not created the empty database yet does so at the next PIN unlock. + if (!secureDatabaseManager.hasEncryptedDatabase() && authenticationManager.pendingWipe() == null) { + return InitialStatus.LOSS_DATABASE + } // A lost PIN slot is recoverable only when the user explicitly created a recovery slot. // The lock screen can unwrap the DEK with that seed; once unlocked, Settings can create @@ -148,7 +161,11 @@ class SecurityManager @Inject constructor( */ fun canResetPinWithoutCurrent(): Boolean = sessionUnlockedWithRecovery && sessionDek != null - /** One PIN attempt; [pin] is wiped. */ + /** + * One PIN attempt from the lock screen; [pin] is wiped. The wipe PIN reports + * [VaultUnlockResult.SUCCESS] like the PIN, with the new, empty vault open (see + * [openErasedVault]). + */ fun unlockWithPin(pin: CharArray): VaultUnlockResult { val attempt = try { authenticationManager.unlockWithPin(pin) @@ -162,6 +179,12 @@ class SecurityManager @Inject constructor( SensitiveDataCleaner.clear(attempt.dek) } + is PinUnlock.Erased -> try { + if (openErasedVault(attempt.dek)) VaultUnlockResult.SUCCESS else VaultUnlockResult.DB_ERROR + } finally { + SensitiveDataCleaner.clear(attempt.dek) + } + PinUnlock.WrongPin -> VaultUnlockResult.INVALID_CREDENTIAL PinUnlock.LockedOut -> VaultUnlockResult.LOCKED_OUT @@ -291,17 +314,75 @@ class SecurityManager @Inject constructor( /** * Opens SQLCipher with an already unwrapped v2 DEK. * Caller retains ownership of [dek] and should wipe it after this call. + * + * While a wipe-PIN erase still has to create the empty database ([WipeStage.DATABASE]), + * whatever is left of the old one is deleted first and the new one is created with [dek]: + * only the new PIN slot can still produce a DEK then. */ fun unlockWithRawKey(dek: ByteArray, allowCreate: Boolean = false): Boolean { if (dek.size != DEK_LENGTH) return false - val ok = secureDatabaseManager.unlockDatabase(dek, allowCreate = allowCreate) + val replaceDatabase = authenticationManager.pendingWipe() == WipeStage.DATABASE + if (replaceDatabase) secureDatabaseManager.wipe() + val ok = secureDatabaseManager.unlockDatabase(dek, allowCreate = allowCreate || replaceDatabase) if (ok) { + if (replaceDatabase) { + authenticationManager.markWipeDatabaseCreated() + completePendingWipe() + } setSessionKey(dek) securityStatus = InitialStatus.MAIN } return ok } + /** + * Phase 3 of a wipe-PIN erase, on the lock screen: [unlockWithRawKey] replaces the old + * database with an empty one opened with [dek] and cleans up the rest. The erased slots are + * already gone (phases 1 and 2, in [AuthenticationManager.unlockWithPin]), so a kill from + * here on ends in an empty vault, which the next start or unlock finishes. + */ + private fun openErasedVault(dek: ByteArray): Boolean { + val opened = unlockWithRawKey(dek) + if (opened) sessionIsErasedVault = true + return opened + } + + /** + * Finishes a wipe-PIN erase (see [WipeStage]); does nothing when none is pending. Safe to + * repeat: every step is a deletion. Runs at startup and once the empty database is open. + * Theme, sorting and auto-lock settings are kept, as on any vault. + */ + fun completePendingWipe() { + val stage = authenticationManager.pendingWipe() ?: return + // Before the empty database exists, whatever is left of the old one goes (never an open one). + if (stage == WipeStage.DATABASE && !secureDatabaseManager.isDatabaseUnlocked()) { + secureDatabaseManager.wipe() + } + authenticationManager.deleteRetiredPinKey() + // Its slot went with the erase; this deletes the Keystore key behind it. + biometricManager.disable() + appFlags.edit().remove(BackupManager.LAST_EXPORT_KEY).commit() + if (stage == WipeStage.CLEANUP) authenticationManager.clearPendingWipe() + } + + /** Whether the open session is the empty vault a wipe-PIN unlock just made. */ + fun isErasedVaultSession(): Boolean = sessionIsErasedVault && sessionDek != null + + // --- wipe PIN ----------------------------------------------------------------------- + + /** + * Sets (or replaces) the wipe PIN; only in an unlocked session, after the caller checked the + * PIN. [pin] is wiped. + */ + fun configureWipePin(pin: CharArray): WipePinChange = try { + if (sessionDek == null) WipePinChange.FAILED else authenticationManager.configureWipePin(pin) + } finally { + SensitiveDataCleaner.clear(pin) + } + + /** Turns the wipe PIN off; only in an unlocked session, after the caller checked the PIN. */ + fun removeWipePin(): Boolean = sessionDek != null && authenticationManager.removeWipePin() + /** * Completes first-run setup only after a PIN slot exists and the bootstrap DEK opens SQLCipher. */ @@ -397,6 +478,7 @@ class SecurityManager @Inject constructor( sessionDek?.let(SensitiveDataCleaner::clear) sessionDek = null sessionUnlockedWithRecovery = false + sessionIsErasedVault = false } fun wipeAndReset() { @@ -415,7 +497,19 @@ class SecurityManager @Inject constructor( authType = authenticationManager.getAuthType(), isBiometricEnabled = isBiometricEnabled(), isUserCreatedSeedKey = seedPhraseManager.hasRecoverySeed(), + hasPinSlot = authenticationManager.hasPinSlot(), + wipePinTurnedOff = authenticationManager.wipePinTurnedOff(), ) } -data class AuthSettings(val authType: AuthType, val isBiometricEnabled: Boolean, val isUserCreatedSeedKey: Boolean) +/** + * The vault's security state as Settings shows it. [wipePinTurnedOff]: the PIN key was reset, + * so any wipe PIN stopped working (whether one was set is never known without it). + */ +data class AuthSettings( + val authType: AuthType, + val isBiometricEnabled: Boolean, + val isUserCreatedSeedKey: Boolean, + val hasPinSlot: Boolean = authType == AuthType.PIN, + val wipePinTurnedOff: Boolean = false, +) diff --git a/app/src/main/java/com/pasich/encly/core/security/SeedPhraseManager.kt b/app/src/main/java/com/pasich/encly/core/security/SeedPhraseManager.kt index ae4e4cb..3c2aeb0 100644 --- a/app/src/main/java/com/pasich/encly/core/security/SeedPhraseManager.kt +++ b/app/src/main/java/com/pasich/encly/core/security/SeedPhraseManager.kt @@ -31,8 +31,8 @@ import javax.inject.Singleton class SeedPhraseManager @Inject constructor(private val store: VaultStore) { companion object { private const val VAULT_PREFIX = "vault." - private const val RECOVERY_PREFIX = "recovery." - private const val BACKUP_PREFIX = "backup." + internal const val RECOVERY_PREFIX = "recovery." + internal const val BACKUP_PREFIX = "backup." private const val VERSION_KEY = "vault.version" private const val RECOVERY_SLOT_KEY = "recovery.slot" private const val RECOVERY_ENABLED_KEY = "recovery.enabled" diff --git a/app/src/main/java/com/pasich/encly/core/security/VaultStore.kt b/app/src/main/java/com/pasich/encly/core/security/VaultStore.kt index 9fe75d0..6580557 100644 --- a/app/src/main/java/com/pasich/encly/core/security/VaultStore.kt +++ b/app/src/main/java/com/pasich/encly/core/security/VaultStore.kt @@ -46,6 +46,9 @@ class VaultStore(private val file: File) { fun contains(key: String): Boolean = synchronized(lock) { load().containsKey(key) } + /** The keys present; for tests that compare what a store reveals by its shape. */ + internal fun keys(): Set = synchronized(lock) { load().keys.toSet() } + fun getBytes(key: String): ByteArray? = synchronized(lock) { load()[key]?.copyOf() } fun getInt(key: String, default: Int): Int = synchronized(lock) { diff --git a/app/src/main/java/com/pasich/encly/data/backup/BackupManager.kt b/app/src/main/java/com/pasich/encly/data/backup/BackupManager.kt index 65388b0..190e901 100644 --- a/app/src/main/java/com/pasich/encly/data/backup/BackupManager.kt +++ b/app/src/main/java/com/pasich/encly/data/backup/BackupManager.kt @@ -98,8 +98,10 @@ class BackupManager @Inject constructor( throw BackupException(BackupError.IO) } - private companion object { - const val TAG = "BackupManager" - const val LAST_EXPORT_KEY = "backup_last_export_at" + companion object { + private const val TAG = "BackupManager" + + /** In the app flags; a wipe-PIN erase removes it with the vault it described. */ + internal const val LAST_EXPORT_KEY = "backup_last_export_at" } } diff --git a/app/src/main/java/com/pasich/encly/presentation/navigation/NavHost.kt b/app/src/main/java/com/pasich/encly/presentation/navigation/NavHost.kt index 36cd895..bdd461f 100644 --- a/app/src/main/java/com/pasich/encly/presentation/navigation/NavHost.kt +++ b/app/src/main/java/com/pasich/encly/presentation/navigation/NavHost.kt @@ -29,6 +29,7 @@ import com.pasich.encly.presentation.screen.onboarding.OnboardingScreen import com.pasich.encly.presentation.screen.settings.AppearanceScreen import com.pasich.encly.presentation.screen.settings.SecuritySettingsScreen import com.pasich.encly.presentation.screen.settings.SettingsScreen +import com.pasich.encly.presentation.screen.settings.WipePinScreen import com.pasich.encly.presentation.screen.trash.TrashScreen import com.pasich.encly.presentation.viewmodel.EditNoteViewModel import com.pasich.encly.presentation.viewmodel.LossRecoveryViewModel @@ -127,6 +128,10 @@ fun AppNavHost(navController: NavHostController, startDestination: String = NavR PinCodeConfigScreen(navController) } + animationScreens(NavRoutes.WipePinRoute.name) { + WipePinScreen(navController) + } + animationScreens(NavRoutes.LockRoute.name) { LockScreen(navController) } diff --git a/app/src/main/java/com/pasich/encly/presentation/navigation/NavRoutes.kt b/app/src/main/java/com/pasich/encly/presentation/navigation/NavRoutes.kt index 5fd771c..04dbe6a 100644 --- a/app/src/main/java/com/pasich/encly/presentation/navigation/NavRoutes.kt +++ b/app/src/main/java/com/pasich/encly/presentation/navigation/NavRoutes.kt @@ -15,6 +15,7 @@ enum class NavRoutes { LossDataRoute, LegacyVaultRoute, PinCodeConfig, + WipePinRoute, LockRoute, BackupRoute, AppearanceRoute, diff --git a/app/src/main/java/com/pasich/encly/presentation/screen/LockScreen.kt b/app/src/main/java/com/pasich/encly/presentation/screen/LockScreen.kt index 5e2d478..6462585 100644 --- a/app/src/main/java/com/pasich/encly/presentation/screen/LockScreen.kt +++ b/app/src/main/java/com/pasich/encly/presentation/screen/LockScreen.kt @@ -63,7 +63,7 @@ fun LockScreen( val unlockReveal = LocalUnlockReveal.current - fun goHome() = navController.leaveLockScreen(unlockReveal, viewModel::isSessionLocked) + fun goHome() = navController.leaveLockScreen(unlockReveal, viewModel::isSessionLocked, viewModel::canReopenNote) // A recovery-phrase unlock means the PIN was forgotten: set a new one before going on. fun goToPinReset() { @@ -132,16 +132,21 @@ private class PinAuth( * Leaves the lock screen for Home, and the note that was open when the app re-locked (see * MainActivity), once the reveal covers the window so Home composes out of sight. If the * session closed again while the reveal played (e.g. the app went to the background), it stays - * on the lock screen rather than open Home over a locked vault. + * on the lock screen rather than open Home over a locked vault. The note is not reopened when + * [canReopenNote] says the open vault is not the one it was in (a wipe-PIN unlock). */ -private fun NavHostController.leaveLockScreen(reveal: UnlockRevealState?, isSessionLocked: () -> Boolean) { +private fun NavHostController.leaveLockScreen( + reveal: UnlockRevealState?, + isSessionLocked: () -> Boolean, + canReopenNote: () -> Boolean, +) { val returnRoute = currentBackStackEntry?.savedStateHandle?.get(RelockReturn.RETURN_ROUTE) reveal.revealThen { if (!isSessionLocked()) { navigate(NavRoutes.HomeRoute.name) { popUpTo(NavRoutes.LockRoute.name) { inclusive = true } } - if (returnRoute != null) navigate(returnRoute) + if (returnRoute != null && canReopenNote()) navigate(returnRoute) } } } diff --git a/app/src/main/java/com/pasich/encly/presentation/screen/settings/SecuritySettingsScreen.kt b/app/src/main/java/com/pasich/encly/presentation/screen/settings/SecuritySettingsScreen.kt index d7e859a..e5f99d8 100644 --- a/app/src/main/java/com/pasich/encly/presentation/screen/settings/SecuritySettingsScreen.kt +++ b/app/src/main/java/com/pasich/encly/presentation/screen/settings/SecuritySettingsScreen.kt @@ -100,6 +100,7 @@ fun SecuritySettingsScreen( vaultBusy = vaultState.busy, actions = SecurityActions( onChangePin = { navController.navigate(NavRoutes.PinCodeConfig.name) }, + onWipePin = { navController.navigate(NavRoutes.WipePinRoute.name) }, onBiometric = { enabled -> if (activity != null) securityViewModel.toggleBiometric(activity, enabled) }, @@ -120,6 +121,7 @@ fun SecuritySettingsScreen( /** What the security page can start. */ private class SecurityActions( val onChangePin: () -> Unit, + val onWipePin: () -> Unit, val onBiometric: (Boolean) -> Unit, val onVaultAction: (BackupAction) -> Unit, val onStrictKeyboard: (Boolean) -> Unit, @@ -170,6 +172,10 @@ private fun SecurityContent( EnclyGroupDivider() BiometricSetting(securityState, actions.onBiometric) } + if (securityState.hasPinSlot) { + EnclyGroupDivider() + WipePinRow(turnedOff = securityState.wipePinTurnedOff, onClick = actions.onWipePin) + } EnclyGroupDivider() AutoLockRow(current = options.autoLock, onSelect = actions.onAutoLock) EnclyGroupDivider() @@ -250,6 +256,21 @@ private fun BiometricSetting( ) } +/** + * "Wipe PIN": always the same row, set or not (only the wipe PIN itself could tell). After a PIN + * key reset it says that any wipe PIN was turned off. + */ +@Composable +private fun WipePinRow(turnedOff: Boolean, onClick: () -> Unit) { + EnclyNavigationRow( + title = stringResource(R.string.wipe_pin_title), + supporting = stringResource(if (turnedOff) R.string.wipe_pin_row_turned_off else R.string.wipe_pin_row_desc), + icon = EnclyIcons.Trash, + onClick = onClick, + modifier = Modifier.padding(horizontal = EnclyTheme.spacing.s), + ) +} + /** * The recovery phrase: once set up it is a status (it is never shown again; a separate row * replaces it with new words). diff --git a/app/src/main/java/com/pasich/encly/presentation/screen/settings/WipePinScreen.kt b/app/src/main/java/com/pasich/encly/presentation/screen/settings/WipePinScreen.kt new file mode 100644 index 0000000..63b27f0 --- /dev/null +++ b/app/src/main/java/com/pasich/encly/presentation/screen/settings/WipePinScreen.kt @@ -0,0 +1,365 @@ +package com.pasich.encly.presentation.screen.settings + +import androidx.activity.compose.BackHandler +import androidx.activity.compose.LocalActivity +import androidx.annotation.StringRes +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.fragment.app.FragmentActivity +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavHostController +import com.pasich.encly.R +import com.pasich.encly.core.security.SensitiveDataCleaner +import com.pasich.encly.core.security.WipePinChange +import com.pasich.encly.presentation.designsystem.CalloutTone +import com.pasich.encly.presentation.designsystem.EnclyButton +import com.pasich.encly.presentation.designsystem.EnclyCallout +import com.pasich.encly.presentation.designsystem.EnclySnackbarHost +import com.pasich.encly.presentation.designsystem.EnclyTextButton +import com.pasich.encly.presentation.designsystem.EnclyTopBar +import com.pasich.encly.presentation.screen.pincode.PinBuffer +import com.pasich.encly.presentation.screen.pincode.PinEntry +import com.pasich.encly.presentation.screen.pincode.PinEntryActions +import com.pasich.encly.presentation.screen.pincode.PinEntryScaffold +import com.pasich.encly.presentation.screen.pincode.PinLockoutTicker +import com.pasich.encly.presentation.screen.pincode.lockoutSecondsLeft +import com.pasich.encly.presentation.screen.pincode.pinLockoutText +import com.pasich.encly.presentation.viewmodel.SecuritySettingsViewModel +import com.pasich.encly.ui.theme.EnclyTheme +import kotlinx.coroutines.launch + +private enum class WipePinStep { VERIFY, OPTIONS, NEW, CONFIRM } + +/** + * Settings → Security → Wipe PIN. The PIN comes first; then the page explains what a wipe PIN + * does and offers to set one (replacing any earlier one) or turn it off. It never says whether + * one is set: only the wipe PIN itself could tell (see AuthenticationManager). The PINs are + * CharArrays that are wiped once used, never Strings (see [PinBuffer]). + */ +private class WipePinState { + var step by mutableStateOf(WipePinStep.VERIFY) + + /** The wipe PIN typed at [WipePinStep.NEW], until the confirmation is compared with it. */ + private var firstPin: CharArray? = null + + val input = PinBuffer() + var errorText by mutableStateOf(null) + var lockoutSeconds by mutableLongStateOf(0L) + + /** Changes on every wrong entry, so the dots shake again. */ + var shakeKey by mutableIntStateOf(0) + + /** A finished action to announce on the options page. */ + var message by mutableStateOf(null) + + val keysEnabled: Boolean get() = !(step == WipePinStep.VERIFY && lockoutSeconds > 0L) + + fun typeDigit(digit: Int) { + if (keysEnabled) input.add(digit) + } + + /** The PIN of the current step is complete: check it, keep it, or compare it. */ + fun onPinComplete(viewModel: SecuritySettingsViewModel) { + // The ViewModel wipes what it is given; everything else is wiped here. + val pin = input.take() + when (step) { + WipePinStep.VERIFY -> viewModel.verifyCurrentPin(pin) { ok -> + onCurrentPinChecked(ok, viewModel.pinLockoutRemainingMillis()) + } + + WipePinStep.NEW -> { + firstPin?.let(SensitiveDataCleaner::clear) + firstPin = pin + errorText = null + step = WipePinStep.CONFIRM + } + + WipePinStep.CONFIRM -> confirm(pin, viewModel) + + WipePinStep.OPTIONS -> SensitiveDataCleaner.clear(pin) + } + } + + fun startNew() { + errorText = null + step = WipePinStep.NEW + } + + /** Back from a PIN step to the options; false when there is nothing to go back to here. */ + fun back(): Boolean { + if (step != WipePinStep.NEW && step != WipePinStep.CONFIRM) return false + clear() + errorText = null + step = WipePinStep.OPTIONS + return true + } + + /** Typed or kept digits do not outlive the screen. */ + fun clear() { + input.clear() + firstPin?.let(SensitiveDataCleaner::clear) + firstPin = null + } + + private fun onCurrentPinChecked(ok: Boolean, lockoutMillis: Long) { + val lockout = lockoutSecondsLeft(lockoutMillis) + errorText = null + when { + ok -> step = WipePinStep.OPTIONS + + // A locked-out PIN is refused even when right: say so, not "wrong PIN". + lockout > 0L -> { + lockoutSeconds = lockout + shakeKey++ + } + + else -> { + errorText = R.string.pin_current_wrong + shakeKey++ + } + } + } + + private fun confirm(pin: CharArray, viewModel: SecuritySettingsViewModel) { + val first = firstPin + firstPin = null + val matches = first != null && pin.contentEquals(first) + first?.let(SensitiveDataCleaner::clear) + if (!matches) { + SensitiveDataCleaner.clear(pin) + restartNew(R.string.pin_mismatch_retry) + return + } + viewModel.setWipePin(pin) { result -> + when (result) { + WipePinChange.SET -> { + message = R.string.wipe_pin_saved + step = WipePinStep.OPTIONS + } + + WipePinChange.SAME_AS_PIN -> restartNew(R.string.wipe_pin_same_as_pin) + + WipePinChange.FAILED -> restartNew(R.string.wipe_pin_failed) + } + } + } + + private fun restartNew(@StringRes error: Int) { + errorText = error + shakeKey++ + step = WipePinStep.NEW + } +} + +@Composable +fun WipePinScreen( + navController: NavHostController, + modifier: Modifier = Modifier, + securityViewModel: SecuritySettingsViewModel = hiltViewModel(), +) { + val state = remember { WipePinState() } + val securityState by securityViewModel.uiState.collectAsStateWithLifecycle() + val activity = LocalActivity.current as? FragmentActivity + val snackbarHostState = remember { SnackbarHostState() } + + BackHandler(enabled = state.step == WipePinStep.NEW || state.step == WipePinStep.CONFIRM) { state.back() } + PinLockoutTicker(state.lockoutSeconds > 0L, securityViewModel::pinLockoutRemainingMillis) { + state.lockoutSeconds = it + } + LaunchedEffect(state.input.length) { + if (state.input.isFull) state.onPinComplete(securityViewModel) + } + DisposableEffect(state) { onDispose { state.clear() } } + WipePinMessageEffect(state.message, snackbarHostState, onShow = { state.message = null }) + + Scaffold( + containerColor = MaterialTheme.colorScheme.surface, + topBar = { + EnclyTopBar( + title = stringResource(R.string.wipe_pin_title), + onBack = { if (!state.back()) navController.popBackStack() }, + ) + }, + snackbarHost = { EnclySnackbarHost(snackbarHostState) }, + modifier = modifier.fillMaxSize(), + ) { paddingValues -> + if (state.step == WipePinStep.OPTIONS) { + WipePinOptions( + info = WipePinInfo( + turnedOff = securityState.wipePinTurnedOff, + biometricOn = securityState.biometricEnable, + ), + actions = WipePinActions( + onSet = state::startNew, + onRemove = { + securityViewModel.removeWipePin { ok -> + state.message = if (ok) R.string.wipe_pin_removed else R.string.wipe_pin_failed + } + }, + onBiometricOff = { + if (activity != null) securityViewModel.toggleBiometric(activity, enable = false) + }, + ), + modifier = Modifier.padding(paddingValues), + ) + } else { + WipePinEntry( + text = wipePinStepText(state), + entered = state.input.length, + entry = WipePinEntryState(enabled = state.keysEnabled, shakeKey = state.shakeKey), + actions = PinEntryActions(onDigit = state::typeDigit, onBackspace = state.input::deleteLast), + modifier = Modifier.padding(paddingValues), + ) + } + } +} + +/** Shows [message] once as a snackbar; [onShow] clears it. */ +@Composable +private fun WipePinMessageEffect(@StringRes message: Int?, snackbarHostState: SnackbarHostState, onShow: () -> Unit) { + val context = LocalContext.current + val currentOnShow by rememberUpdatedState(onShow) + // Clearing the message restarts this effect, which must not cancel the snackbar it opened. + val snackbarScope = rememberCoroutineScope() + LaunchedEffect(message) { + val id = message ?: return@LaunchedEffect + currentOnShow() + snackbarScope.launch { snackbarHostState.showSnackbar(context.getString(id)) } + } +} + +/** What the options page shows besides the actions. */ +private class WipePinInfo(val turnedOff: Boolean, val biometricOn: Boolean) + +private class WipePinActions(val onSet: () -> Unit, val onRemove: () -> Unit, val onBiometricOff: () -> Unit) + +@Composable +private fun WipePinOptions(info: WipePinInfo, actions: WipePinActions, modifier: Modifier = Modifier) { + val spacing = EnclyTheme.spacing + Column( + verticalArrangement = Arrangement.spacedBy(spacing.m), + modifier = modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = spacing.gutter, vertical = spacing.s), + ) { + if (info.turnedOff) { + EnclyCallout(text = stringResource(R.string.wipe_pin_turned_off), tone = CalloutTone.WARNING) + } + Text( + text = stringResource(R.string.wipe_pin_intro), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + EnclyCallout(text = stringResource(R.string.wipe_pin_backups)) + if (info.biometricOn) { + Column(verticalArrangement = Arrangement.spacedBy(spacing.xxs)) { + EnclyCallout(text = stringResource(R.string.wipe_pin_biometric_warning), tone = CalloutTone.WARNING) + EnclyTextButton( + text = stringResource(R.string.wipe_pin_biometric_off), + onClick = actions.onBiometricOff, + ) + } + } + Text( + text = stringResource(R.string.wipe_pin_limits), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Column( + verticalArrangement = Arrangement.spacedBy(spacing.xxs), + modifier = Modifier.fillMaxWidth().padding(top = spacing.s), + ) { + EnclyButton(text = stringResource(R.string.wipe_pin_set), onClick = actions.onSet) + EnclyTextButton( + text = stringResource(R.string.wipe_pin_remove), + onClick = actions.onRemove, + destructive = true, + modifier = Modifier.fillMaxWidth(), + ) + } + } +} + +/** A PIN step's title and subtitle, and an error or lockout message. */ +private class WipePinStepText(@param:StringRes val title: Int, @param:StringRes val subtitle: Int, val message: String?) + +/** How the keypad of a PIN step behaves: off during a lockout, and the shake. */ +private class WipePinEntryState(val enabled: Boolean, val shakeKey: Int) + +@Composable +private fun wipePinStepText(state: WipePinState): WipePinStepText { + val lockout = state.lockoutSeconds + .takeIf { it > 0L && state.step == WipePinStep.VERIFY } + ?.let { pinLockoutText(it) } + return when (state.step) { + WipePinStep.VERIFY -> WipePinStepText( + title = R.string.pin_change_current_title, + subtitle = R.string.wipe_pin_current_subtitle, + message = lockout ?: state.errorText?.let { stringResource(it) }, + ) + + WipePinStep.CONFIRM -> WipePinStepText( + title = R.string.wipe_pin_confirm_title, + subtitle = R.string.wipe_pin_confirm_subtitle, + message = state.errorText?.let { stringResource(it) }, + ) + + else -> WipePinStepText( + title = R.string.wipe_pin_new_title, + subtitle = R.string.wipe_pin_new_subtitle, + message = state.errorText?.let { stringResource(it) }, + ) + } +} + +@Composable +private fun WipePinEntry( + text: WipePinStepText, + entered: Int, + entry: WipePinEntryState, + actions: PinEntryActions, + modifier: Modifier = Modifier, +) { + val message = text.message + PinEntryScaffold( + title = stringResource(text.title), + // An error or the lockout countdown takes the sub-heading slot, as on the lock screen. + subtitle = message ?: stringResource(text.subtitle), + subtitleIsError = message != null, + compact = true, + modifier = modifier, + ) { + PinEntry( + entered = entered, + error = message != null && entered == 0, + shakeKey = entry.shakeKey, + enabled = entry.enabled, + actions = actions, + ) + } +} diff --git a/app/src/main/java/com/pasich/encly/presentation/viewmodel/LockViewModel.kt b/app/src/main/java/com/pasich/encly/presentation/viewmodel/LockViewModel.kt index d8e3292..e5af910 100644 --- a/app/src/main/java/com/pasich/encly/presentation/viewmodel/LockViewModel.kt +++ b/app/src/main/java/com/pasich/encly/presentation/viewmodel/LockViewModel.kt @@ -58,6 +58,12 @@ class LockViewModel @Inject constructor( /** Whether the session is closed (again); an unlock reveal then must not open Home. */ fun isSessionLocked(): Boolean = sessionLockManager.locked.value + /** + * Whether the note open before the re-lock may be reopened: not in the empty vault a + * wipe-PIN unlock made, where it does not exist (an empty editor would give the erase away). + */ + fun canReopenNote(): Boolean = !securityManager.isErasedVaultSession() + /** Unlocks with [pin], which is wiped. */ fun authenticatePin(pin: CharArray, onResult: (PinUnlockResult) -> Unit) { launchUnlock(onResult) { diff --git a/app/src/main/java/com/pasich/encly/presentation/viewmodel/SecuritySettingsViewModel.kt b/app/src/main/java/com/pasich/encly/presentation/viewmodel/SecuritySettingsViewModel.kt index 556b770..6fb8353 100644 --- a/app/src/main/java/com/pasich/encly/presentation/viewmodel/SecuritySettingsViewModel.kt +++ b/app/src/main/java/com/pasich/encly/presentation/viewmodel/SecuritySettingsViewModel.kt @@ -12,6 +12,7 @@ import com.pasich.encly.core.security.BiometricStatus import com.pasich.encly.core.security.KeyboardPrivacy import com.pasich.encly.core.security.SecurityManager import com.pasich.encly.core.security.SensitiveDataCleaner +import com.pasich.encly.core.security.WipePinChange import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow @@ -61,6 +62,8 @@ class SecuritySettingsViewModel @Inject constructor( authType = authSettings.authType, biometricEnable = authSettings.isBiometricEnabled, biometricStatus = securityManager.biometricStatus(), + hasPinSlot = authSettings.hasPinSlot, + wipePinTurnedOff = authSettings.wipePinTurnedOff, loaded = true, ) } @@ -110,6 +113,27 @@ class SecuritySettingsViewModel @Inject constructor( } } + /** + * Makes [pin] the wipe PIN, replacing any earlier one; [pin] is wiped. Only reached after + * [verifyCurrentPin] in the same screen. + */ + fun setWipePin(pin: CharArray, onResult: (WipePinChange) -> Unit) { + viewModelScope.launch { + val result = withContext(Dispatchers.Default) { securityManager.configureWipePin(pin) } + if (result == WipePinChange.SET) _uiState.value = _uiState.value.copy(wipePinTurnedOff = false) + onResult(result) + } + } + + /** Turns the wipe PIN off (whether one was set is never known). */ + fun removeWipePin(onResult: (Boolean) -> Unit) { + viewModelScope.launch { + val ok = withContext(Dispatchers.IO) { securityManager.removeWipePin() } + if (ok) _uiState.value = _uiState.value.copy(wipePinTurnedOff = false) + onResult(ok) + } + } + /** * Enabling biometrics performs the auth-bound CryptoObject enrollment itself. * Disabling an existing biometric slot requires a fresh strong-biometric confirmation. @@ -157,6 +181,10 @@ class SecuritySettingsViewModel @Inject constructor( val isUserCreatedSeedKey: Boolean = false, val biometricEnable: Boolean = false, val biometricStatus: BiometricStatus = BiometricStatus.UNAVAILABLE, + /** A PIN slot exists, so a wipe PIN can be set next to it. */ + val hasPinSlot: Boolean = false, + /** The PIN key was reset and any wipe PIN with it; Settings asks to set it again. */ + val wipePinTurnedOff: Boolean = false, /** The first [refresh] finished; until then the page shows nothing rather than guesses. */ val loaded: Boolean = false, ) { diff --git a/app/src/main/res/values-de/strings_security.xml b/app/src/main/res/values-de/strings_security.xml index 1e85213..0d711c8 100644 --- a/app/src/main/res/values-de/strings_security.xml +++ b/app/src/main/res/values-de/strings_security.xml @@ -68,4 +68,25 @@ PIN, Wiederherstellungsphrase und Fingerabdruck öffnen je einen eigenen verschlüsselten Slot. Der Fingerabdruck wird jedes Mal verlangt. Gefahrenbereich 5 Sekunden halten zum Löschen + + Lösch-PIN + Eine zweite PIN, die deine Notizen löscht, wenn du sie auf dem Sperrbildschirm eingibst + Ausgeschaltet, als dein PIN-Schlüssel zurückgesetzt wurde. Lege sie neu fest, wenn du eine hattest. + Wenn dich jemand zwingt, Encly zu öffnen, gib statt deiner PIN die Lösch-PIN ein. Encly löscht deine Notizen, Aufgaben und Labels ohne sichtbares Zeichen und öffnet sich als leerer Tresor. Das funktioniert nur auf dem Sperrbildschirm. + Bereits exportierte Backup-Dateien bleiben unberührt. Sie lassen sich weiterhin mit deiner Wiederherstellungsphrase öffnen und sind der einzige Weg zurück. + Biometrisches Entsperren bleibt an: Jemand kann deinen Finger auf den Sensor zwingen. Schalte es aus, wenn das für dich ein Risiko ist. + Biometrisches Entsperren ausschalten + Das schützt dich, wenn dich jemand zum Entsperren zwingt, nicht vor einer forensischen Untersuchung des Telefons. Während einer Sperre nach Fehlversuchen funktioniert auch die Lösch-PIN nicht. Encly zeigt nie an, ob eine Lösch-PIN festgelegt ist: Eine neue ersetzt die alte, und wenn du deine PIN auf die Lösch-PIN änderst, wird sie ausgeschaltet. + Lösch-PIN festlegen + Lösch-PIN ausschalten + Bestätige deine PIN, um die Lösch-PIN einzurichten + Lösch-PIN erstellen + 6 Ziffern, anders als deine PIN + Lösch-PIN bestätigen + Gib die Lösch-PIN noch einmal ein + Die Lösch-PIN muss sich von deiner PIN unterscheiden + Die Lösch-PIN konnte nicht gespeichert werden. Bitte versuche es erneut. + Lösch-PIN festgelegt + Lösch-PIN ausgeschaltet + Dein PIN-Schlüssel wurde zurückgesetzt, daher ist eine eventuelle Lösch-PIN ausgeschaltet. Lege sie neu fest, wenn du eine möchtest. diff --git a/app/src/main/res/values-es/strings_security.xml b/app/src/main/res/values-es/strings_security.xml index 1a1ed21..0d5321f 100644 --- a/app/src/main/res/values-es/strings_security.xml +++ b/app/src/main/res/values-es/strings_security.xml @@ -73,4 +73,25 @@ El PIN, la frase de recuperación y la huella abren cada uno su propio espacio cifrado. La huella se pide siempre. Zona de peligro Mantén 5 segundos para borrar + + PIN de borrado + Un segundo PIN que borra tus notas si lo escribes en la pantalla de bloqueo + Se desactivó al restablecerse la clave de tu PIN. Vuelve a configurarlo si lo usabas. + Si alguien te obliga a abrir Encly, escribe el PIN de borrado en lugar de tu PIN. Encly borra tus notas, tareas y etiquetas sin dejar señal y se abre como una bóveda vacía. Solo funciona en la pantalla de bloqueo. + Los archivos de copia que ya exportaste no se tocan. Se siguen abriendo con tu frase de recuperación y son la única forma de volver atrás. + El desbloqueo biométrico sigue activado: alguien puede obligarte a poner el dedo en el sensor. Desactívalo si eso es un riesgo para ti. + Desactivar el desbloqueo biométrico + Te protege cuando alguien te obliga a desbloquear, no frente a un análisis forense del teléfono. Durante un bloqueo por intentos fallidos el PIN de borrado tampoco funciona. Encly nunca muestra si hay un PIN de borrado: uno nuevo sustituye al anterior, y si cambias tu PIN al PIN de borrado, este se desactiva. + Configurar PIN de borrado + Desactivar PIN de borrado + Confirma tu PIN para configurar el PIN de borrado + Crea un PIN de borrado + 6 dígitos, distintos de tu PIN + Confirma el PIN de borrado + Vuelve a escribir el PIN de borrado + El PIN de borrado debe ser distinto de tu PIN + No se pudo guardar el PIN de borrado. Inténtalo de nuevo. + PIN de borrado configurado + PIN de borrado desactivado + Se restableció la clave de tu PIN, así que el PIN de borrado, si tenías uno, se desactivó. Vuelve a configurarlo si lo quieres. diff --git a/app/src/main/res/values-fr/strings_security.xml b/app/src/main/res/values-fr/strings_security.xml index 539c1f9..fe62051 100644 --- a/app/src/main/res/values-fr/strings_security.xml +++ b/app/src/main/res/values-fr/strings_security.xml @@ -73,4 +73,25 @@ Le code PIN, la phrase de récupération et l’empreinte ouvrent chacun leur propre emplacement chiffré. L’empreinte est demandée à chaque fois. Zone de danger Maintenez 5 secondes pour effacer + + PIN d\'effacement + Un second PIN qui efface vos notes s\'il est saisi sur l\'écran de verrouillage + Désactivé lors de la réinitialisation de la clé de votre PIN. Définissez-le à nouveau si vous en utilisiez un. + Si quelqu\'un vous force à ouvrir Encly, saisissez le PIN d\'effacement au lieu de votre PIN. Encly efface vos notes, tâches et libellés sans aucun signe et s\'ouvre comme un coffre vide. Cela ne fonctionne que sur l\'écran de verrouillage. + Les fichiers de sauvegarde déjà exportés ne sont pas touchés. Ils s\'ouvrent toujours avec votre phrase de récupération et sont le seul moyen de revenir en arrière. + Le déverrouillage biométrique reste actif : quelqu\'un peut vous forcer à poser le doigt sur le capteur. Désactivez-le si c\'est un risque pour vous. + Désactiver le déverrouillage biométrique + Cela vous protège quand quelqu\'un vous force à déverrouiller, pas contre une analyse forensique du téléphone. Pendant un blocage après des essais ratés, le PIN d\'effacement ne fonctionne pas non plus. Encly n\'indique jamais si un PIN d\'effacement est défini : un nouveau remplace l\'ancien, et si vous changez votre PIN pour le PIN d\'effacement, celui-ci est désactivé. + Définir le PIN d\'effacement + Désactiver le PIN d\'effacement + Confirmez votre PIN pour configurer le PIN d\'effacement + Créez un PIN d\'effacement + 6 chiffres, différents de votre PIN + Confirmez le PIN d\'effacement + Saisissez à nouveau le PIN d\'effacement + Le PIN d\'effacement doit être différent de votre PIN + Impossible d\'enregistrer le PIN d\'effacement. Veuillez réessayer. + PIN d\'effacement défini + PIN d\'effacement désactivé + La clé de votre PIN a été réinitialisée : un éventuel PIN d\'effacement a donc été désactivé. Définissez-le à nouveau si vous le souhaitez. diff --git a/app/src/main/res/values-it/strings_security.xml b/app/src/main/res/values-it/strings_security.xml index 8fc7c47..37152d3 100644 --- a/app/src/main/res/values-it/strings_security.xml +++ b/app/src/main/res/values-it/strings_security.xml @@ -73,4 +73,25 @@ PIN, frase di recupero e impronta aprono ciascuno il proprio spazio cifrato. L’impronta viene chiesta ogni volta. Zona pericolosa Tieni premuto 5 secondi per cancellare + + PIN di cancellazione + Un secondo PIN che cancella le tue note se lo digiti nella schermata di blocco + Disattivato quando la chiave del PIN è stata reimpostata. Impostalo di nuovo se ne usavi uno. + Se qualcuno ti costringe ad aprire Encly, digita il PIN di cancellazione invece del tuo PIN. Encly cancella note, attività ed etichette senza lasciare segni e si apre come una cassaforte vuota. Funziona solo nella schermata di blocco. + I file di backup che hai già esportato non vengono toccati. Si aprono ancora con la frase di recupero e sono l\'unico modo per tornare indietro. + Lo sblocco biometrico resta attivo: qualcuno può costringerti ad appoggiare il dito sul sensore. Disattivalo se per te è un rischio. + Disattiva lo sblocco biometrico + Ti protegge quando qualcuno ti costringe a sbloccare, non da un\'analisi forense del telefono. Durante un blocco dopo tentativi errati anche il PIN di cancellazione non funziona. Encly non mostra mai se un PIN di cancellazione è impostato: uno nuovo sostituisce il precedente, e se cambi il tuo PIN nel PIN di cancellazione, questo viene disattivato. + Imposta PIN di cancellazione + Disattiva PIN di cancellazione + Conferma il tuo PIN per configurare il PIN di cancellazione + Crea un PIN di cancellazione + 6 cifre, diverse dal tuo PIN + Conferma il PIN di cancellazione + Digita di nuovo il PIN di cancellazione + Il PIN di cancellazione deve essere diverso dal tuo PIN + Impossibile salvare il PIN di cancellazione. Riprova. + PIN di cancellazione impostato + PIN di cancellazione disattivato + La chiave del tuo PIN è stata reimpostata, quindi l\'eventuale PIN di cancellazione è stato disattivato. Impostalo di nuovo se ti serve. diff --git a/app/src/main/res/values-nl/strings_security.xml b/app/src/main/res/values-nl/strings_security.xml index de3b762..4eec494 100644 --- a/app/src/main/res/values-nl/strings_security.xml +++ b/app/src/main/res/values-nl/strings_security.xml @@ -68,4 +68,25 @@ Pincode, herstelzin en vingerafdruk openen elk hun eigen versleutelde slot. De vingerafdruk is elke keer nodig. Gevarenzone Houd 5 seconden vast om te wissen + + Wis-pincode + Een tweede pincode die je notities wist als je hem op het vergrendelscherm invoert + Uitgeschakeld toen de sleutel van je pincode werd gereset. Stel hem opnieuw in als je er een gebruikte. + Als iemand je dwingt Encly te openen, voer dan de wis-pincode in plaats van je pincode in. Encly wist je notities, taken en labels zonder zichtbaar teken en opent als een lege kluis. Dit werkt alleen op het vergrendelscherm. + Back-upbestanden die je al hebt geëxporteerd, blijven onaangeroerd. Ze openen nog steeds met je herstelzin en zijn de enige weg terug. + Biometrisch ontgrendelen blijft aan: iemand kan je vinger op de sensor dwingen. Zet het uit als dat voor jou een risico is. + Biometrisch ontgrendelen uitzetten + Dit beschermt je als iemand je dwingt te ontgrendelen, niet tegen forensisch onderzoek van de telefoon. Tijdens een blokkering na mislukte pogingen werkt ook de wis-pincode niet. Encly laat nooit zien of er een wis-pincode is ingesteld: een nieuwe vervangt de oude, en als je je pincode wijzigt in de wis-pincode, wordt die uitgeschakeld. + Wis-pincode instellen + Wis-pincode uitzetten + Bevestig je pincode om de wis-pincode in te stellen + Maak een wis-pincode + 6 cijfers, anders dan je pincode + Bevestig de wis-pincode + Voer de wis-pincode nogmaals in + De wis-pincode moet anders zijn dan je pincode + Kan de wis-pincode niet opslaan. Probeer het opnieuw. + Wis-pincode ingesteld + Wis-pincode uitgezet + De sleutel van je pincode is gereset, dus een eventuele wis-pincode is uitgeschakeld. Stel hem opnieuw in als je er een wilt. diff --git a/app/src/main/res/values-pl/strings_security.xml b/app/src/main/res/values-pl/strings_security.xml index b7ce815..6e310cb 100644 --- a/app/src/main/res/values-pl/strings_security.xml +++ b/app/src/main/res/values-pl/strings_security.xml @@ -78,4 +78,25 @@ PIN, fraza odzyskiwania i odcisk palca otwierają własne zaszyfrowane miejsca. Odcisk jest wymagany za każdym razem. Strefa zagrożenia Przytrzymaj 5 sekund, aby usunąć + + PIN kasujący + Drugi PIN, który kasuje notatki, gdy wpiszesz go na ekranie blokady + Wyłączony po zresetowaniu klucza PIN. Ustaw go ponownie, jeśli go używałeś. + Jeśli ktoś zmusza cię do otwarcia Encly, wpisz PIN kasujący zamiast swojego PIN-u. Encly bez żadnego śladu skasuje notatki, zadania i etykiety i otworzy się jako pusty sejf. Działa tylko na ekranie blokady. + Wyeksportowane już pliki kopii zapasowych pozostają nietknięte. Nadal otwierają się frazą odzyskiwania i są jedyną drogą powrotu. + Odblokowanie biometryczne pozostaje włączone: ktoś może zmusić cię do przyłożenia palca do czytnika. Wyłącz je, jeśli to dla ciebie ryzyko. + Wyłącz odblokowanie biometryczne + Chroni, gdy ktoś zmusza cię do odblokowania, ale nie przed analizą kryminalistyczną telefonu. Podczas blokady po nieudanych próbach PIN kasujący również nie działa. Encly nigdy nie pokazuje, czy PIN kasujący jest ustawiony: nowy zastępuje stary, a jeśli zmienisz swój PIN na PIN kasujący, zostanie on wyłączony. + Ustaw PIN kasujący + Wyłącz PIN kasujący + Potwierdź swój PIN, aby skonfigurować PIN kasujący + Utwórz PIN kasujący + 6 cyfr, inny niż twój PIN + Potwierdź PIN kasujący + Wpisz PIN kasujący ponownie + PIN kasujący musi różnić się od twojego PIN-u + Nie udało się zapisać PIN-u kasującego. Spróbuj ponownie. + PIN kasujący ustawiony + PIN kasujący wyłączony + Klucz twojego PIN-u został zresetowany, więc ewentualny PIN kasujący został wyłączony. Ustaw go ponownie, jeśli chcesz. diff --git a/app/src/main/res/values-pt/strings_security.xml b/app/src/main/res/values-pt/strings_security.xml index 858820d..0034747 100644 --- a/app/src/main/res/values-pt/strings_security.xml +++ b/app/src/main/res/values-pt/strings_security.xml @@ -73,4 +73,25 @@ PIN, frase de recuperação e impressão digital abrem cada um o seu próprio espaço criptografado. A digital é pedida sempre. Zona de perigo Segure 5 segundos para apagar + + PIN de apagamento + Um segundo PIN que apaga suas notas quando digitado na tela de bloqueio + Desativado quando a chave do seu PIN foi redefinida. Defina-o de novo se você usava um. + Se alguém obrigar você a abrir o Encly, digite o PIN de apagamento em vez do seu PIN. O Encly apaga suas notas, tarefas e etiquetas sem deixar sinal e abre como um cofre vazio. Só funciona na tela de bloqueio. + Os arquivos de backup que você já exportou não são tocados. Eles continuam abrindo com sua frase de recuperação e são o único caminho de volta. + O desbloqueio biométrico continua ativado: alguém pode forçar seu dedo no sensor. Desative-o se isso for um risco para você. + Desativar desbloqueio biométrico + Isso protege você quando alguém o obriga a desbloquear, não contra uma perícia forense do telefone. Durante um bloqueio por tentativas erradas, o PIN de apagamento também não funciona. O Encly nunca mostra se há um PIN de apagamento: um novo substitui o anterior, e se você mudar seu PIN para o PIN de apagamento, ele é desativado. + Definir PIN de apagamento + Desativar PIN de apagamento + Confirme seu PIN para configurar o PIN de apagamento + Crie um PIN de apagamento + 6 dígitos, diferentes do seu PIN + Confirme o PIN de apagamento + Digite o PIN de apagamento de novo + O PIN de apagamento precisa ser diferente do seu PIN + Não foi possível salvar o PIN de apagamento. Tente novamente. + PIN de apagamento definido + PIN de apagamento desativado + A chave do seu PIN foi redefinida, então o PIN de apagamento, se houver, foi desativado. Defina-o de novo se quiser um. diff --git a/app/src/main/res/values-uk/strings_security.xml b/app/src/main/res/values-uk/strings_security.xml index bf7f301..2179fa3 100644 --- a/app/src/main/res/values-uk/strings_security.xml +++ b/app/src/main/res/values-uk/strings_security.xml @@ -78,4 +78,25 @@ PIN, фраза відновлення й відбиток відкривають кожен свій зашифрований слот. Відбиток потрібен щоразу. Небезпечна зона Утримуйте 5 секунд, щоб стерти + + PIN для стирання + Другий PIN, що стирає нотатки, якщо ввести його на екрані блокування + Вимкнено після скидання ключа PIN. Задайте його знову, якщо користувалися ним. + Якщо вас змушують відкрити Encly, введіть PIN для стирання замість свого PIN. Encly непомітно зітре нотатки, завдання й мітки та відкриється як порожнє сховище. Працює лише на екрані блокування. + Уже експортовані файли резервних копій не зачіпаються. Вони й далі відкриваються фразою відновлення і є єдиним шляхом назад. + Розблокування біометрією лишається ввімкненим: вас можуть змусити прикласти палець. Вимкніть його, якщо це для вас ризик. + Вимкнути розблокування біометрією + Це захищає, коли вас змушують розблокувати телефон, але не від криміналістичної експертизи. Під час блокування після невдалих спроб PIN для стирання теж не діє. Encly ніколи не показує, чи його задано: новий замінює старий, а якщо змінити свій PIN на PIN для стирання, його буде вимкнено. + Задати PIN для стирання + Вимкнути PIN для стирання + Підтвердьте свій PIN, щоб налаштувати PIN для стирання + Створіть PIN для стирання + 6 цифр, відмінні від вашого PIN + Підтвердьте PIN для стирання + Введіть PIN для стирання ще раз + PIN для стирання має відрізнятися від вашого PIN + Не вдалося зберегти PIN для стирання. Спробуйте ще раз. + PIN для стирання задано + PIN для стирання вимкнено + Ключ вашого PIN було скинуто, тож PIN для стирання, якщо він був, вимкнено. Задайте його знову, якщо він вам потрібен. diff --git a/app/src/main/res/values/strings_security.xml b/app/src/main/res/values/strings_security.xml index f11567f..4e95529 100644 --- a/app/src/main/res/values/strings_security.xml +++ b/app/src/main/res/values/strings_security.xml @@ -68,4 +68,25 @@ PIN, recovery phrase and fingerprint each open their own encrypted slot. Fingerprint needs your finger every time. Danger zone Hold for 5 seconds to erase + + Wipe PIN + A second PIN that erases your notes when typed on the lock screen + Turned off when your PIN key was reset. Set it again if you used one. + If someone makes you open Encly, type the wipe PIN instead of your PIN. Encly erases your notes, tasks and tags without any sign and opens as an empty vault. It works only on the lock screen. + Backup files you already exported are not touched. They still open with your recovery phrase and are the only way back. + Biometric unlock stays on: someone can force your finger onto the sensor. Turn it off if that is a risk for you. + Turn off biometric unlock + It protects you when someone forces you to unlock, not against a forensic examination of the phone. During a lockout the wipe PIN can\'t be used either. Encly never shows whether a wipe PIN is set: a new one replaces the old one, and changing your PIN to the wipe PIN turns it off. + Set wipe PIN + Turn off wipe PIN + Confirm your PIN to set up the wipe PIN + Create a wipe PIN + 6 digits, different from your PIN + Confirm the wipe PIN + Enter the wipe PIN again + The wipe PIN must be different from your PIN + Couldn\'t save the wipe PIN. Please try again. + Wipe PIN set + Wipe PIN turned off + Your PIN key was reset, so any wipe PIN you had was turned off. Set it again if you want one. diff --git a/app/src/test/java/com/pasich/encly/core/security/AuthenticationManagerTest.kt b/app/src/test/java/com/pasich/encly/core/security/AuthenticationManagerTest.kt index 8dafef9..2da3105 100644 --- a/app/src/test/java/com/pasich/encly/core/security/AuthenticationManagerTest.kt +++ b/app/src/test/java/com/pasich/encly/core/security/AuthenticationManagerTest.kt @@ -56,7 +56,8 @@ class AuthenticationManagerTest { // A copied slot on another device (another hardware key) cannot be opened even with // the right PIN: the attacker has no way to compute the hardware half of the KEK. assertTrue(manager.configurePin(pin("123456"), ByteArray(32) { 7 })) - val otherDevice = AuthenticationManager(VaultStore(file), FakePinFactor().apply { reset() }, FakeLockoutClock()) + val otherDevice = + AuthenticationManager(VaultStore(file), FakePinFactor().apply { reset(PinKeySlot.A) }, FakeLockoutClock()) assertEquals(PinUnlock.WrongPin, otherDevice.unlockWithPin(pin("123456"))) } @@ -134,13 +135,17 @@ class AuthenticationManagerTest { } @Test - fun eachPinSlotUsesAFreshSaltAndIv() { + fun aNewPinSlotKeepsTheSaltButGetsAFreshIv() { + // The salt is kept so that a wipe PIN set before keeps opening its slot. val dek = ByteArray(32) { 1 } assertTrue(manager.configurePin(pin("123456"), dek)) val first = store.getBytes("pin.slot")!! assertTrue(manager.configurePin(pin("123456"), dek)) + val second = store.getBytes("pin.slot")!! - assertNotEquals(first.toList(), store.getBytes("pin.slot")!!.toList()) + assertEquals(first.copyOfRange(1, 17).toList(), second.copyOfRange(1, 17).toList()) + assertNotEquals(first.copyOfRange(17, 29).toList(), second.copyOfRange(17, 29).toList()) + assertNotEquals(first.toList(), second.toList()) } @Test diff --git a/app/src/test/java/com/pasich/encly/core/security/PinLockoutTest.kt b/app/src/test/java/com/pasich/encly/core/security/PinLockoutTest.kt index c4fad7e..75f10fa 100644 --- a/app/src/test/java/com/pasich/encly/core/security/PinLockoutTest.kt +++ b/app/src/test/java/com/pasich/encly/core/security/PinLockoutTest.kt @@ -122,7 +122,7 @@ class PinLockoutTest { // The fifth attempt dies inside the key derivation (the process is killed there). var lockoutOnDiskDuringKdf = -1L val dying = object : PinHardwareFactor by factor { - override fun mac(data: ByteArray): ByteArray { + override fun mac(slot: PinKeySlot, data: ByteArray): ByteArray { // What a fresh process would read from disk at this moment. lockoutOnDiskDuringKdf = AuthenticationManager(VaultStore(file), factor, clock).remainingLockoutMillis() throw ProcessDeath() diff --git a/app/src/test/java/com/pasich/encly/core/security/WipePinTest.kt b/app/src/test/java/com/pasich/encly/core/security/WipePinTest.kt new file mode 100644 index 0000000..390bcbf --- /dev/null +++ b/app/src/test/java/com/pasich/encly/core/security/WipePinTest.kt @@ -0,0 +1,506 @@ +package com.pasich.encly.core.security + +import com.pasich.encly.data.backup.BackupManager +import com.pasich.encly.data.database.SecureDatabaseManager +import com.pasich.encly.testutil.FakeLockoutClock +import com.pasich.encly.testutil.FakePinFactor +import com.pasich.encly.testutil.InMemorySharedPreferences +import com.pasich.encly.testutil.anyByteArray +import com.pasich.encly.testutil.tempVaultFile +import com.pasich.encly.testutil.tempVaultStore +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.mockito.ArgumentMatchers.anyBoolean +import org.mockito.ArgumentMatchers.eq +import org.mockito.Mockito.inOrder +import org.mockito.Mockito.mock +import org.mockito.Mockito.never +import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` +import java.io.File + +/** + * The wipe PIN (issue #52): a second PIN that, typed on the lock screen, erases the vault and + * opens an empty one, while nothing in the store shows whether one is set. + */ +class WipePinTest { + private lateinit var file: File + private lateinit var store: VaultStore + private lateinit var factor: FakePinFactor + private lateinit var clock: FakeLockoutClock + private lateinit var auth: AuthenticationManager + private val dek = ByteArray(DEK_LENGTH) { 0x3C } + + @Before + fun setUp() { + file = tempVaultFile() + store = VaultStore(file) + factor = FakePinFactor() + clock = FakeLockoutClock() + auth = AuthenticationManager(store, factor, clock) + } + + // --- nothing shows whether a wipe PIN is set ----------------------------------------- + + @Test + fun theStoreHasTheSameKeysAndSizesWithAndWithoutAWipePin() { + assertTrue(auth.configurePin(pin(PIN), dek)) + val otherStore = tempVaultStore() + val other = AuthenticationManager(otherStore, FakePinFactor(), FakeLockoutClock()) + assertTrue(other.configurePin(pin(PIN), dek)) + assertEquals(WipePinChange.SET, other.configureWipePin(pin(WIPE_PIN))) + + assertEquals(shape(store), shape(otherStore)) + } + + @Test + fun aDecoyAndARealWipeSlotHaveTheSameLengthAndVersion() { + assertTrue(auth.configurePin(pin(PIN), dek)) + val decoy = store.getBytes(WIPE_SLOT)!! + assertEquals(WipePinChange.SET, auth.configureWipePin(pin(WIPE_PIN))) + val real = store.getBytes(WIPE_SLOT)!! + + assertEquals(decoy.size, real.size) + assertEquals(decoy[0], real[0]) + // No salt of its own: the PIN slot's salt appears nowhere in it. + val salt = store.getBytes(PIN_SLOT)!!.copyOfRange(1, 1 + SALT_SIZE) + assertFalse(real.toList().windowed(SALT_SIZE).any { it == salt.toList() }) + } + + @Test + fun anExistingVaultKeepsItsPinSlotBytesWhenTheDecoyIsAdded() { + // A store as builds before the wipe PIN left it: a PIN slot, no wipe slot, no key slot. + assertTrue(auth.configurePin(pin(PIN), dek)) + store.edit { + remove(WIPE_SLOT) + remove(KEY_SLOT) + } + val before = store.getBytes(PIN_SLOT)!! + + assertTrue(auth.ensureWipeSlot()) + val decoy = store.getBytes(WIPE_SLOT)!! + assertTrue(auth.ensureWipeSlot()) + + assertArrayEquals(before, store.getBytes(PIN_SLOT)) + assertArrayEquals("a second run writes nothing", decoy, store.getBytes(WIPE_SLOT)) + assertEquals(PinKeySlot.A.ordinal, store.getInt(KEY_SLOT, -1)) + assertArrayEquals(dek, (auth.unlockWithPin(pin(PIN)) as PinUnlock.Success).dek) + } + + @Test + fun aVaultWithoutAPinSlotGetsNoDecoy() { + assertTrue(auth.ensureWipeSlot()) + + assertFalse(store.contains(WIPE_SLOT)) + } + + @Test + fun anOlderBuildThatIgnoresTheNewKeysStillOpensTheVault() { + // Downgrade: an older build reads the same file and knows only pin.slot under key A. + assertTrue(auth.configurePin(pin(PIN), dek)) + assertEquals(WipePinChange.SET, auth.configureWipePin(pin(WIPE_PIN))) + val olderStore = VaultStore(file) + assertFalse(olderStore.isCorrupt()) + olderStore.edit { removePrefix("pin.wipe.") } + olderStore.edit { remove(KEY_SLOT) } + + val older = AuthenticationManager(olderStore, factor, FakeLockoutClock()) + + assertArrayEquals(dek, (older.unlockWithPin(pin(PIN)) as PinUnlock.Success).dek) + } + + // --- the erase ----------------------------------------------------------------------- + + @Test + fun theWipePinErasesEverySlotAndTheOldPinIsWrongAfterwards() { + val seed = SeedPhraseManager(store) + assertTrue(seed.initializeVault(seed.generateMnemonic().chars)) + val oldDek = seed.copyBootstrapKey()!! + assertTrue(auth.configurePin(pin(PIN), oldDek)) + store.edit { + putBytes("bio.slot", ByteArray(60) { 1 }) + putBoolean("auth.biometric_enabled", true) + } + auth.unlockWithPin(pin("000000")) + assertEquals(WipePinChange.SET, auth.configureWipePin(pin(WIPE_PIN))) + + val erased = auth.unlockWithPin(pin(WIPE_PIN)) + + assertTrue(erased is PinUnlock.Erased) + val newDek = (erased as PinUnlock.Erased).dek + assertFalse(newDek.contentEquals(oldDek)) + assertEquals(WipeStage.DATABASE, auth.pendingWipe()) + val keys = keysOf(store) + assertTrue(keys.none { it.startsWith("recovery.") || it.startsWith("backup.") || it.startsWith("bio.") }) + assertTrue(keys.none { it.startsWith("lockout.") || it == "auth.biometric_enabled" }) + // vault.version stays: the empty vault is a committed vault, not onboarding. + assertTrue(seed.verificationKeyData()) + assertFalse(seed.hasRecoverySeed()) + assertFalse(seed.hasBackupKey()) + // The PIN slot moved to the other Keystore key; the old one goes with the cleanup. + assertEquals(PinKeySlot.B.ordinal, store.getInt(KEY_SLOT, -1)) + assertTrue(factor.hasKey(PinKeySlot.B)) + auth.deleteRetiredPinKey() + assertFalse(factor.hasKey(PinKeySlot.A)) + + assertEquals(PinUnlock.WrongPin, auth.unlockWithPin(pin(PIN))) + // The wipe PIN is now the PIN of the empty vault, and wipes nothing more. + assertArrayEquals(newDek, (auth.unlockWithPin(pin(WIPE_PIN)) as PinUnlock.Success).dek) + } + + @Test + fun aSecondEraseMovesTheKeyBackToTheFirstSlot() { + assertTrue(auth.configurePin(pin(PIN), dek)) + assertEquals(WipePinChange.SET, auth.configureWipePin(pin(WIPE_PIN))) + assertTrue(auth.unlockWithPin(pin(WIPE_PIN)) is PinUnlock.Erased) + auth.deleteRetiredPinKey() + assertTrue(auth.clearPendingWipe()) + assertEquals(WipePinChange.SET, auth.configureWipePin(pin(PIN))) + + assertTrue(auth.unlockWithPin(pin(PIN)) is PinUnlock.Erased) + + assertEquals(PinKeySlot.A.ordinal, store.getInt(KEY_SLOT, -1)) + assertTrue(auth.unlockWithPin(pin(PIN)) is PinUnlock.Success) + assertEquals(PinUnlock.WrongPin, auth.unlockWithPin(pin(WIPE_PIN))) + } + + @Test + fun withoutASecondKeySlotTheEraseStillReplacesTheSlots() { + assertTrue(auth.configurePin(pin(PIN), dek)) + assertEquals(WipePinChange.SET, auth.configureWipePin(pin(WIPE_PIN))) + factor.failingReset = true + + val erased = auth.unlockWithPin(pin(WIPE_PIN)) + + assertTrue(erased is PinUnlock.Erased) + assertEquals(PinKeySlot.A.ordinal, store.getInt(KEY_SLOT, -1)) + assertEquals(PinUnlock.WrongPin, auth.unlockWithPin(pin(PIN))) + assertTrue(auth.unlockWithPin(pin(WIPE_PIN)) is PinUnlock.Success) + } + + @Test + fun insideTheOpenVaultTheWipePinIsJustAWrongPin() { + assertTrue(auth.configurePin(pin(PIN), dek)) + assertEquals(WipePinChange.SET, auth.configureWipePin(pin(WIPE_PIN))) + + assertFalse(auth.verifyPinAuth(pin(WIPE_PIN))) + + assertNull(auth.pendingWipe()) + assertTrue(auth.verifyPinAuth(pin(PIN))) + } + + @Test + fun withoutAWipePinNothingButThePinOpens() { + assertTrue(auth.configurePin(pin(PIN), dek)) + + assertEquals(PinUnlock.WrongPin, auth.unlockWithPin(pin(WIPE_PIN))) + assertNull(auth.pendingWipe()) + } + + @Test + fun everyAttemptRunsTheHardwareHalfOnce() { + assertTrue(auth.configurePin(pin(PIN), dek)) + assertEquals(WipePinChange.SET, auth.configureWipePin(pin(WIPE_PIN))) + val before = factor.calls + + auth.unlockWithPin(pin(PIN)) + auth.unlockWithPin(pin("000000")) + + assertEquals(before + 2, factor.calls) + } + + @Test + fun theWipePinTakesAboutAsLongAsThePin() { + // One KDF run either way; the erase only adds a key, a seal and a store write. + val normal = (1..2).minOf { + val manager = configured(withWipePin = true) + timed { manager.unlockWithPin(pin(PIN)) } + } + val wipe = (1..2).minOf { + val manager = configured(withWipePin = true) + timed { assertTrue(manager.unlockWithPin(pin(WIPE_PIN)) is PinUnlock.Erased) } + } + + assertTrue("normal $normal ms, wipe $wipe ms", wipe <= normal * 2 + TIMING_SLACK_MS) + } + + // --- setting, changing and removing --------------------------------------------------- + + @Test + fun theWipePinMustBeSixDigitsAndDifferFromThePin() { + assertTrue(auth.configurePin(pin(PIN), dek)) + val decoy = store.getBytes(WIPE_SLOT)!! + + assertEquals(WipePinChange.SAME_AS_PIN, auth.configureWipePin(pin(PIN))) + assertEquals(WipePinChange.FAILED, auth.configureWipePin(pin("12345"))) + assertEquals(WipePinChange.FAILED, auth.configureWipePin(pin("12345a"))) + + assertArrayEquals(decoy, store.getBytes(WIPE_SLOT)) + } + + @Test + fun aWipePinNeedsAPinSlot() { + assertEquals(WipePinChange.FAILED, auth.configureWipePin(pin(WIPE_PIN))) + } + + @Test + fun changingThePinKeepsTheWipePin() { + assertTrue(auth.configurePin(pin(PIN), dek)) + assertEquals(WipePinChange.SET, auth.configureWipePin(pin(WIPE_PIN))) + + assertTrue(auth.configurePin(pin("246810"), dek)) + + assertEquals(PinUnlock.WrongPin, auth.unlockWithPin(pin(PIN))) + assertTrue(auth.unlockWithPin(pin(WIPE_PIN)) is PinUnlock.Erased) + } + + @Test + fun makingTheWipePinThePinTurnsTheWipePinOff() { + assertTrue(auth.configurePin(pin(PIN), dek)) + assertEquals(WipePinChange.SET, auth.configureWipePin(pin(WIPE_PIN))) + + assertTrue(auth.configurePin(pin(WIPE_PIN), dek)) + + assertArrayEquals(dek, (auth.unlockWithPin(pin(WIPE_PIN)) as PinUnlock.Success).dek) + assertNull(auth.pendingWipe()) + } + + @Test + fun removingTheWipePinMakesItAWrongPin() { + assertTrue(auth.configurePin(pin(PIN), dek)) + assertEquals(WipePinChange.SET, auth.configureWipePin(pin(WIPE_PIN))) + val real = store.getBytes(WIPE_SLOT)!! + + assertTrue(auth.removeWipePin()) + + assertEquals(real.size, store.getBytes(WIPE_SLOT)!!.size) + assertEquals(PinUnlock.WrongPin, auth.unlockWithPin(pin(WIPE_PIN))) + assertNull(auth.pendingWipe()) + } + + @Test + fun aPinKeyResetTurnsTheWipePinOffAndSaysSo() { + assertTrue(auth.configurePin(pin(PIN), dek)) + assertEquals(WipePinChange.SET, auth.configureWipePin(pin(WIPE_PIN))) + assertFalse(auth.wipePinTurnedOff()) + // The Keystore key was invalidated; a new PIN is set after a recovery-phrase unlock. + factor.lost = true + + assertTrue(auth.configurePin(pin("246810"), dek)) + + assertTrue(auth.wipePinTurnedOff()) + assertEquals(PinUnlock.WrongPin, auth.unlockWithPin(pin(WIPE_PIN))) + assertEquals(WipePinChange.SET, auth.configureWipePin(pin(WIPE_PIN))) + assertFalse(auth.wipePinTurnedOff()) + assertTrue(auth.unlockWithPin(pin(WIPE_PIN)) is PinUnlock.Erased) + } + + @Test + fun removingTheWipePinAlsoClearsTheNotice() { + assertTrue(auth.configurePin(pin(PIN), dek)) + factor.lost = true + assertTrue(auth.configurePin(pin(PIN), dek)) + assertTrue(auth.wipePinTurnedOff()) + + assertTrue(auth.removeWipePin()) + + assertFalse(auth.wipePinTurnedOff()) + } + + // --- lockout --------------------------------------------------------------------------- + + @Test + fun aRunningLockoutRefusesTheWipePinWithoutErasing() { + assertTrue(auth.configurePin(pin(PIN), dek)) + assertEquals(WipePinChange.SET, auth.configureWipePin(pin(WIPE_PIN))) + repeat(AuthenticationManager.MAX_ATTEMPTS) { auth.unlockWithPin(pin("000000")) } + + assertEquals(PinUnlock.LockedOut, auth.unlockWithPin(pin(WIPE_PIN))) + + assertNull(auth.pendingWipe()) + clock.elapsed += AuthenticationManager.FIRST_LOCKOUT_MS + assertArrayEquals(dek, (auth.unlockWithPin(pin(PIN)) as PinUnlock.Success).dek) + } + + @Test + fun theWipePinIsCountedAndThenClearsTheLockoutLikeTheRightPin() { + assertTrue(auth.configurePin(pin(PIN), dek)) + assertEquals(WipePinChange.SET, auth.configureWipePin(pin(WIPE_PIN))) + repeat(AuthenticationManager.MAX_ATTEMPTS - 1) { auth.unlockWithPin(pin("000000")) } + + assertTrue(auth.unlockWithPin(pin(WIPE_PIN)) is PinUnlock.Erased) + + assertTrue(keysOf(store).none { it.startsWith("lockout.") }) + assertEquals(PinUnlock.WrongPin, auth.unlockWithPin(pin("000000"))) + assertEquals(0L, auth.remainingLockoutMillis()) + } + + // --- phase 3 and a kill between the phases --------------------------------------------- + + @Test + fun aKillBeforeTheCleanupIsFinishedAtTheNextStart() { + val vault = Vault() + // Phases 1 and 2 ran; the process died before phase 3. + assertTrue(vault.auth.unlockWithPin(pin(WIPE_PIN)) is PinUnlock.Erased) + + val restarted = vault.restart() + assertEquals(InitialStatus.AUTH, restarted.resolveInitialStatus()) + + verify(vault.database).wipe() + verify(vault.biometric).disable() + assertFalse(vault.prefs.contains(BackupManager.LAST_EXPORT_KEY)) + assertFalse(vault.factor.hasKey(PinKeySlot.A)) + // The empty database is created by the next unlock, with the new DEK. + assertEquals(WipeStage.DATABASE, vault.auth.pendingWipe()) + `when`(vault.database.unlockDatabase(anyByteArray(), eq(true))).thenReturn(true) + + assertEquals(VaultUnlockResult.SUCCESS, restarted.unlockWithPin(pin(WIPE_PIN))) + + verify(vault.database).unlockDatabase(anyByteArray(), eq(true)) + assertNull(vault.auth.pendingWipe()) + } + + @Test + fun aKillAfterTheEmptyDatabaseExistsOnlyCleansUp() { + val vault = Vault() + assertTrue(vault.auth.unlockWithPin(pin(WIPE_PIN)) is PinUnlock.Erased) + assertTrue(vault.auth.markWipeDatabaseCreated()) + `when`(vault.database.hasEncryptedDatabase()).thenReturn(true) + + assertEquals(InitialStatus.AUTH, vault.restart().resolveInitialStatus()) + + verify(vault.database, never()).wipe() + verify(vault.biometric).disable() + assertNull(vault.auth.pendingWipe()) + } + + @Test + fun theWipePinOnTheLockScreenOpensAnEmptyVault() { + val vault = Vault() + `when`(vault.database.unlockDatabase(anyByteArray(), anyBoolean())).thenReturn(true) + + assertEquals(VaultUnlockResult.SUCCESS, vault.security.unlockWithPin(pin(WIPE_PIN))) + + // The old database goes before the new one is created (same file name). + val order = inOrder(vault.database) + order.verify(vault.database).wipe() + order.verify(vault.database).unlockDatabase(anyByteArray(), eq(true)) + assertNull(vault.auth.pendingWipe()) + assertFalse(vault.factor.hasKey(PinKeySlot.A)) + assertFalse(vault.prefs.contains(BackupManager.LAST_EXPORT_KEY)) + assertTrue(vault.security.isErasedVaultSession()) + // Onboarding stays committed: a restart asks for the PIN of the empty vault. + `when`(vault.database.hasEncryptedDatabase()).thenReturn(true) + assertEquals(InitialStatus.AUTH, vault.restart().resolveInitialStatus()) + + vault.security.lock() + assertFalse(vault.security.isErasedVaultSession()) + } + + @Test + fun aNormalUnlockIsNotAnErasedVaultSession() { + val vault = Vault() + `when`(vault.database.unlockDatabase(anyByteArray(), anyBoolean())).thenReturn(true) + + assertEquals(VaultUnlockResult.SUCCESS, vault.security.unlockWithPin(pin(PIN))) + + verify(vault.database, never()).wipe() + assertFalse(vault.security.isErasedVaultSession()) + assertTrue(vault.prefs.contains(BackupManager.LAST_EXPORT_KEY)) + } + + @Test + fun startupAddsTheDecoyToAnExistingVault() { + val vault = Vault(withWipePin = false) + vault.store.edit { + remove(WIPE_SLOT) + remove(KEY_SLOT) + } + val before = vault.store.getBytes(PIN_SLOT)!! + `when`(vault.database.hasEncryptedDatabase()).thenReturn(true) + + assertEquals(InitialStatus.AUTH, vault.restart().resolveInitialStatus()) + + assertArrayEquals(before, vault.store.getBytes(PIN_SLOT)) + assertNotNull(vault.store.getBytes(WIPE_SLOT)) + } + + @Test + fun theWipePinCanOnlyBeChangedInAnOpenVault() { + val vault = Vault(withWipePin = false) + `when`(vault.database.unlockDatabase(anyByteArray(), anyBoolean())).thenReturn(true) + + assertEquals(WipePinChange.FAILED, vault.security.configureWipePin(pin(WIPE_PIN))) + assertFalse(vault.security.removeWipePin()) + + assertEquals(VaultUnlockResult.SUCCESS, vault.security.unlockWithPin(pin(PIN))) + val typed = pin(WIPE_PIN) + assertEquals(WipePinChange.SET, vault.security.configureWipePin(typed)) + assertTrue(typed.all { it == '\u0000' }) + assertTrue(vault.security.removeWipePin()) + } + + /** A committed vault with a recovery phrase, the PIN, a fingerprint and (by default) a wipe PIN. */ + private inner class Vault(withWipePin: Boolean = true) { + val store = this@WipePinTest.store + val factor = this@WipePinTest.factor + val auth = this@WipePinTest.auth + val prefs = InMemorySharedPreferences() + val seed = SeedPhraseManager(store) + val database: SecureDatabaseManager = mock(SecureDatabaseManager::class.java) + val biometric: BiometricManager = mock(BiometricManager::class.java) + val security = SecurityManager(prefs, store, seed, database, auth, biometric) + + init { + assertTrue(seed.initializeVault(seed.generateMnemonic().chars)) + val bootstrap = seed.copyBootstrapKey()!! + assertTrue(auth.configurePin(pin(PIN), bootstrap)) + if (withWipePin) assertEquals(WipePinChange.SET, auth.configureWipePin(pin(WIPE_PIN))) + prefs.edit() + .putBoolean("onboarding_shown_v3", true) + .putLong(BackupManager.LAST_EXPORT_KEY, 1L) + .commit() + } + + /** The same files read by a new process. */ + fun restart() = SecurityManager(prefs, store, seed, database, auth, biometric) + } + + private fun configured(withWipePin: Boolean): AuthenticationManager { + val manager = AuthenticationManager(tempVaultStore(), FakePinFactor(), FakeLockoutClock()) + assertTrue(manager.configurePin(pin(PIN), dek)) + if (withWipePin) assertEquals(WipePinChange.SET, manager.configureWipePin(pin(WIPE_PIN))) + return manager + } + + private fun timed(block: () -> Unit): Long { + val start = System.nanoTime() + block() + return (System.nanoTime() - start) / NANOS_PER_MILLI + } + + /** Every key and the length of its value. */ + private fun shape(store: VaultStore): Map = keysOf(store).associateWith { store.getBytes(it)!!.size } + + private fun keysOf(store: VaultStore): Set = store.keys().toSortedSet() + + private fun pin(value: String) = value.toCharArray() + + private companion object { + const val PIN = "135790" + const val WIPE_PIN = "864200" + const val DEK_LENGTH = 32 + const val SALT_SIZE = 16 + const val NANOS_PER_MILLI = 1_000_000L + const val TIMING_SLACK_MS = 250L + const val PIN_SLOT = "pin.slot" + const val WIPE_SLOT = "pin.wipe.slot" + const val KEY_SLOT = "pin.key_slot" + } +} diff --git a/app/src/test/java/com/pasich/encly/presentation/viewmodel/SecuritySettingsViewModelTest.kt b/app/src/test/java/com/pasich/encly/presentation/viewmodel/SecuritySettingsViewModelTest.kt index bab8051..88a9bcf 100644 --- a/app/src/test/java/com/pasich/encly/presentation/viewmodel/SecuritySettingsViewModelTest.kt +++ b/app/src/test/java/com/pasich/encly/presentation/viewmodel/SecuritySettingsViewModelTest.kt @@ -9,6 +9,7 @@ import com.pasich.encly.core.security.AutoLock import com.pasich.encly.core.security.BiometricStatus import com.pasich.encly.core.security.KeyboardPrivacy import com.pasich.encly.core.security.SecurityManager +import com.pasich.encly.core.security.WipePinChange import com.pasich.encly.testutil.InMemorySharedPreferences import com.pasich.encly.testutil.MockActivity import com.pasich.encly.testutil.answerCallback @@ -151,6 +152,59 @@ class SecuritySettingsViewModelTest { assertEquals(LOCKOUT_MS, viewModel.pinLockoutRemainingMillis()) } + @Test + fun aWipePinIsHandedOnAndClearsTheTurnedOffNotice() = runTest { + `when`(security.getSettingsAuth()).thenReturn( + AuthSettings( + AuthType.PIN, + isBiometricEnabled = false, + isUserCreatedSeedKey = false, + wipePinTurnedOff = true, + ), + ) + viewModel.refresh() + viewModel.uiState.first { it.wipePinTurnedOff } + `when`(security.configureWipePin(anyCharArray())).thenReturn(WipePinChange.SET) + val result = CompletableDeferred() + + viewModel.setWipePin("864200".toCharArray()) { result.complete(it) } + + assertEquals(WipePinChange.SET, result.await()) + assertFalse(viewModel.uiState.value.wipePinTurnedOff) + } + + @Test + fun aRefusedWipePinKeepsTheNotice() = runTest { + `when`(security.getSettingsAuth()).thenReturn( + AuthSettings( + AuthType.PIN, + isBiometricEnabled = false, + isUserCreatedSeedKey = false, + wipePinTurnedOff = true, + ), + ) + viewModel.refresh() + viewModel.uiState.first { it.wipePinTurnedOff } + `when`(security.configureWipePin(anyCharArray())).thenReturn(WipePinChange.SAME_AS_PIN) + val result = CompletableDeferred() + + viewModel.setWipePin("135790".toCharArray()) { result.complete(it) } + + assertEquals(WipePinChange.SAME_AS_PIN, result.await()) + assertTrue(viewModel.uiState.value.wipePinTurnedOff) + } + + @Test + fun turningTheWipePinOffReportsTheResult() = runTest { + `when`(security.removeWipePin()).thenReturn(true) + val result = CompletableDeferred() + + viewModel.removeWipePin { result.complete(it) } + + assertTrue(result.await()) + verify(security).removeWipePin() + } + @Test fun enablingBiometricsEnrollsAndShowsTheResult() = runTest { viewModel.uiState.first { it.loaded } diff --git a/app/src/test/java/com/pasich/encly/testutil/SecurityFakes.kt b/app/src/test/java/com/pasich/encly/testutil/SecurityFakes.kt index 5c579ef..da595a7 100644 --- a/app/src/test/java/com/pasich/encly/testutil/SecurityFakes.kt +++ b/app/src/test/java/com/pasich/encly/testutil/SecurityFakes.kt @@ -3,6 +3,7 @@ package com.pasich.encly.testutil import com.pasich.encly.core.security.LockoutClock import com.pasich.encly.core.security.PinFactorException import com.pasich.encly.core.security.PinHardwareFactor +import com.pasich.encly.core.security.PinKeySlot import com.pasich.encly.core.security.VaultStore import java.io.File import java.nio.file.Files @@ -11,32 +12,38 @@ import javax.crypto.Mac import javax.crypto.spec.SecretKeySpec /** - * A [PinHardwareFactor] with a software HMAC key standing in for the Keystore one. [lost] and - * [failing] simulate an invalidated key and a transient Keystore error; [calls] counts MACs, - * i.e. PIN guesses that reached the "hardware". + * A [PinHardwareFactor] with software HMAC keys standing in for the Keystore ones, one per + * [PinKeySlot]. [lost] and [failing] simulate an invalidated key and a transient Keystore error + * (for every slot); [failingReset] a key that cannot be generated. [calls] counts MACs, i.e. + * PIN guesses that reached the "hardware". */ internal class FakePinFactor : PinHardwareFactor { - private var key: ByteArray? = null + private val keys = mutableMapOf() var lost = false var failing = false + var failingReset = false var calls = 0 private set var resets = 0 private set - override fun ensureKey() { - if (key == null) reset() + fun hasKey(slot: PinKeySlot): Boolean = slot in keys + + override fun ensureKey(slot: PinKeySlot) { + if (slot !in keys) reset(slot) } - override fun reset() { - key = ByteArray(32).also { SecureRandom().nextBytes(it) } + override fun reset(slot: PinKeySlot) { + keys.remove(slot) + if (failingReset) throw PinFactorException(lost = false) + keys[slot] = ByteArray(32).also { SecureRandom().nextBytes(it) } lost = false resets++ } - override fun mac(data: ByteArray): ByteArray { + override fun mac(slot: PinKeySlot, data: ByteArray): ByteArray { if (failing) throw PinFactorException(lost = false) - val current = key + val current = keys[slot] if (lost || current == null) throw PinFactorException(lost = true) calls++ return Mac.getInstance("HmacSHA256").run { @@ -45,8 +52,8 @@ internal class FakePinFactor : PinHardwareFactor { } } - override fun delete() { - key = null + override fun delete(slot: PinKeySlot) { + keys.remove(slot) } } diff --git a/app/src/test/java/com/pasich/encly/ui/screens/SecurityScreensTest.kt b/app/src/test/java/com/pasich/encly/ui/screens/SecurityScreensTest.kt index fd0fc55..4deb19b 100644 --- a/app/src/test/java/com/pasich/encly/ui/screens/SecurityScreensTest.kt +++ b/app/src/test/java/com/pasich/encly/ui/screens/SecurityScreensTest.kt @@ -12,15 +12,19 @@ import cash.z.ecc.android.bip39.Mnemonics.MnemonicCode import cash.z.ecc.android.bip39.Mnemonics.WordCount import com.pasich.encly.R import com.pasich.encly.core.security.BiometricStatus +import com.pasich.encly.core.security.WipePinChange import com.pasich.encly.presentation.navigation.NavRoutes import com.pasich.encly.presentation.screen.PinCodeConfigScreen import com.pasich.encly.presentation.screen.settings.SecuritySettingsScreen +import com.pasich.encly.presentation.screen.settings.WipePinScreen import com.pasich.encly.presentation.viewmodel.BackupStep import com.pasich.encly.presentation.viewmodel.BackupViewModel import com.pasich.encly.testutil.anyCharArray import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test +import org.mockito.Mockito.never +import org.mockito.Mockito.verify import org.mockito.Mockito.`when` class SecurityScreensTest : ComposeScreenTest() { @@ -204,4 +208,96 @@ class SecurityScreensTest : ComposeScreenTest() { rule.onNodeWithText(str(R.string.pin_reset_subtitle)).assertIsDisplayed() assertEquals(0, countText(str(R.string.pin_change_bar_title))) } + + // --- wipe PIN ------------------------------------------------------------------------- + + private fun showWipePin(pinIsRight: Boolean = true) { + `when`(app.security.verifyPin(anyCharArray())).thenReturn(pinIsRight) + setNavScreen( + viewModels(app.securitySettings()), + route = NavRoutes.WipePinRoute.name, + start = NavRoutes.SecuritySettingsRoute.name, + ) { nav -> WipePinScreen(nav) } + navigateTo(NavRoutes.WipePinRoute.name) + } + + @Test + fun theWipePinRowOpensTheWipePinPage() { + showSecurity(recoveryPhrase = true) + + scrollToText(str(R.string.wipe_pin_row_desc)) + rule.onNodeWithText(str(R.string.wipe_pin_row_desc)).performClick() + + waitFor { currentRoute() == NavRoutes.WipePinRoute.name } + } + + @Test + fun theWipePinPageAsksForThePinFirst() { + showWipePin(pinIsRight = false) + + rule.onNodeWithText(str(R.string.wipe_pin_current_subtitle)).assertIsDisplayed() + typePin("000000") + + waitForText(str(R.string.pin_current_wrong)) + assertEquals(0, countText(str(R.string.wipe_pin_set))) + } + + @Test + fun aWipePinIsConfirmedAndSaved() { + val saved = mutableListOf() + val passed = mutableListOf() + showWipePin() + `when`(app.security.configureWipePin(anyCharArray())).thenAnswer { + val pin = it.getArgument(0) + passed += pin + saved += String(pin) + pin.fill('\u0000') + WipePinChange.SET + } + + typePin("123456") + waitForText(str(R.string.wipe_pin_intro)) + rule.onNodeWithText(str(R.string.wipe_pin_backups)).assertIsDisplayed() + scrollToText(str(R.string.wipe_pin_set)) + rule.onNodeWithText(str(R.string.wipe_pin_set)).performClick() + waitForText(str(R.string.wipe_pin_new_title)) + typePin("864200") + waitForText(str(R.string.wipe_pin_confirm_title)) + typePin("864200") + + waitForText(str(R.string.wipe_pin_saved)) + assertEquals(listOf("864200"), saved) + assertTrue(passed.single().all { it == '\u0000' }) + } + + @Test + fun aWipePinEqualToThePinIsRefused() { + showWipePin() + `when`(app.security.configureWipePin(anyCharArray())).thenReturn(WipePinChange.SAME_AS_PIN) + + typePin("123456") + waitForText(str(R.string.wipe_pin_intro)) + scrollToText(str(R.string.wipe_pin_set)) + rule.onNodeWithText(str(R.string.wipe_pin_set)).performClick() + typePin("123456") + waitForText(str(R.string.wipe_pin_confirm_title)) + typePin("123456") + + waitForText(str(R.string.wipe_pin_same_as_pin)) + rule.onNodeWithText(str(R.string.wipe_pin_new_title)).assertIsDisplayed() + } + + @Test + fun theWipePinCanBeTurnedOff() { + showWipePin() + `when`(app.security.removeWipePin()).thenReturn(true) + + typePin("123456") + waitForText(str(R.string.wipe_pin_intro)) + scrollToText(str(R.string.wipe_pin_remove)) + rule.onNodeWithText(str(R.string.wipe_pin_remove)).performClick() + + waitForText(str(R.string.wipe_pin_removed)) + verify(app.security, never()).configureWipePin(anyCharArray()) + } } From 1c55f0754863f095a571a1396732c0461d20fabf Mon Sep 17 00:00:00 2001 From: pasichdev Date: Mon, 5 Oct 2026 00:15:27 +0300 Subject: [PATCH 03/19] feat: import notes from My Notes in one hand-off Encly is the successor of My Notes, so it can now receive everything My Notes hands over in one step. My Notes starts com.pasich.encly.action.IMPORT_FROM_MY_NOTES for result with a content:// URI to a ZIP holding handoff.json (mynotes-handoff, schema 1). The hand-off is one way: Encly exposes nothing readable to My Notes. Before anything is read, the caller must be com.pasich.mynotes signed with a pinned certificate (hasSigningCertificate on API 28+, a single GET_SIGNATURES signer on 26-27). The ZIP is copied to a private temp file, read strictly with size, record, depth and zip-bomb limits, and deleted afterwards whatever happens. The vault must be unlocked, and a preview says what comes over and what stays behind (attachments, pins) before anything is imported. My Notes data is mapped here, so the block format stays private to Encly: Editor.js paragraphs, headers, lists, checklists and delimiters become Encly blocks, inline HTML becomes plain text, trashed notes go to the trash, task categories become tags. The import goes through the backup MERGE path in one transaction; tags are matched by uid or by name, and a repeated hand-off adds nothing. The result goes back to My Notes as counts or a reason code. The Play App Signing certificate of My Notes still has to be added to the pinned set before release. Refs: #46 --- CHANGELOG.md | 4 + PRIVACY.md | 10 + SECURITY.md | 25 +++ app/src/main/AndroidManifest.xml | 26 +++ .../pasich/encly/ImportFromMyNotesActivity.kt | 161 +++++++++++++++ .../java/com/pasich/encly/MainActivity.kt | 28 +-- .../encly/data/backup/BackupImporter.kt | 60 ++++-- .../pasich/encly/data/backup/BackupManager.kt | 4 +- .../pasich/encly/data/handoff/EditorJson.kt | 112 +++++++++++ .../encly/data/handoff/HandoffStaging.kt | 43 ++++ .../pasich/encly/data/handoff/InlineHtml.kt | 57 ++++++ .../encly/data/handoff/MyNotesBlockMapper.kt | 67 +++++++ .../data/handoff/MyNotesCallerVerifier.kt | 87 +++++++++ .../encly/data/handoff/MyNotesHandoff.kt | 77 ++++++++ .../data/handoff/MyNotesHandoffMapper.kt | 143 ++++++++++++++ .../data/handoff/MyNotesHandoffReader.kt | 165 ++++++++++++++++ .../encly/presentation/screen/LockExits.kt | 14 ++ .../encly/presentation/screen/LockScreen.kt | 64 ++++-- .../screen/handoff/ImportFromMyNotesScreen.kt | 170 ++++++++++++++++ .../viewmodel/ImportFromMyNotesViewModel.kt | 151 ++++++++++++++ .../main/res/values-de/strings_handoff.xml | 20 ++ .../main/res/values-es/strings_handoff.xml | 20 ++ .../main/res/values-fr/strings_handoff.xml | 20 ++ .../main/res/values-it/strings_handoff.xml | 20 ++ .../main/res/values-nl/strings_handoff.xml | 20 ++ .../main/res/values-pl/strings_handoff.xml | 20 ++ .../main/res/values-pt/strings_handoff.xml | 20 ++ .../main/res/values-uk/strings_handoff.xml | 20 ++ app/src/main/res/values/strings_handoff.xml | 20 ++ .../encly/ImportFromMyNotesActivityTest.kt | 59 ++++++ .../encly/data/handoff/HandoffFixtures.kt | 92 +++++++++ .../encly/data/handoff/HandoffStagingTest.kt | 63 ++++++ .../encly/data/handoff/InlineHtmlTest.kt | 49 +++++ .../data/handoff/MyNotesBlockMapperTest.kt | 183 +++++++++++++++++ .../data/handoff/MyNotesCallerVerifierTest.kt | 91 +++++++++ .../data/handoff/MyNotesHandoffImportTest.kt | 145 ++++++++++++++ .../data/handoff/MyNotesHandoffReaderTest.kt | 184 ++++++++++++++++++ 37 files changed, 2470 insertions(+), 44 deletions(-) create mode 100644 app/src/main/java/com/pasich/encly/ImportFromMyNotesActivity.kt create mode 100644 app/src/main/java/com/pasich/encly/data/handoff/EditorJson.kt create mode 100644 app/src/main/java/com/pasich/encly/data/handoff/HandoffStaging.kt create mode 100644 app/src/main/java/com/pasich/encly/data/handoff/InlineHtml.kt create mode 100644 app/src/main/java/com/pasich/encly/data/handoff/MyNotesBlockMapper.kt create mode 100644 app/src/main/java/com/pasich/encly/data/handoff/MyNotesCallerVerifier.kt create mode 100644 app/src/main/java/com/pasich/encly/data/handoff/MyNotesHandoff.kt create mode 100644 app/src/main/java/com/pasich/encly/data/handoff/MyNotesHandoffMapper.kt create mode 100644 app/src/main/java/com/pasich/encly/data/handoff/MyNotesHandoffReader.kt create mode 100644 app/src/main/java/com/pasich/encly/presentation/screen/LockExits.kt create mode 100644 app/src/main/java/com/pasich/encly/presentation/screen/handoff/ImportFromMyNotesScreen.kt create mode 100644 app/src/main/java/com/pasich/encly/presentation/viewmodel/ImportFromMyNotesViewModel.kt create mode 100644 app/src/main/res/values-de/strings_handoff.xml create mode 100644 app/src/main/res/values-es/strings_handoff.xml create mode 100644 app/src/main/res/values-fr/strings_handoff.xml create mode 100644 app/src/main/res/values-it/strings_handoff.xml create mode 100644 app/src/main/res/values-nl/strings_handoff.xml create mode 100644 app/src/main/res/values-pl/strings_handoff.xml create mode 100644 app/src/main/res/values-pt/strings_handoff.xml create mode 100644 app/src/main/res/values-uk/strings_handoff.xml create mode 100644 app/src/main/res/values/strings_handoff.xml create mode 100644 app/src/test/java/com/pasich/encly/ImportFromMyNotesActivityTest.kt create mode 100644 app/src/test/java/com/pasich/encly/data/handoff/HandoffFixtures.kt create mode 100644 app/src/test/java/com/pasich/encly/data/handoff/HandoffStagingTest.kt create mode 100644 app/src/test/java/com/pasich/encly/data/handoff/InlineHtmlTest.kt create mode 100644 app/src/test/java/com/pasich/encly/data/handoff/MyNotesBlockMapperTest.kt create mode 100644 app/src/test/java/com/pasich/encly/data/handoff/MyNotesCallerVerifierTest.kt create mode 100644 app/src/test/java/com/pasich/encly/data/handoff/MyNotesHandoffImportTest.kt create mode 100644 app/src/test/java/com/pasich/encly/data/handoff/MyNotesHandoffReaderTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index d26b088..c356d3b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,10 @@ IzzyOnDroid) and used as the GitHub Release notes. that never set one (a random decoy slot is added at startup without asking for the PIN). Exported backups are not touched. See SECURITY.md → Wipe PIN for what it does and does not protect against. +- Import from My Notes: My Notes can hand its notes (trash included), tasks, tags and task + categories over to Encly on the same device. Encly accepts it only from the genuine My Notes + app, after you unlock and confirm a preview; a repeated hand-off adds nothing twice. Tags and + task categories are matched by name; attachments, images and pinning do not come over. ### ⚠️ Compatibility diff --git a/PRIVACY.md b/PRIVACY.md index 1abec5c..766e86f 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -83,6 +83,16 @@ Anyone who has both the file and your 12 words can read that backup, and an expo be revoked, so keep the words and the file apart. See [SECURITY.md → Encrypted backups](SECURITY.md#encrypted-backups) for the technical details. +## Import from My Notes + +If you also use My Notes (the developer's earlier notes app), it can move its notes, tasks, tags +and task categories into Encly. This happens **only on your device**, only when you start it in +My Notes, and only after you unlock Encly and confirm what will be imported. Encly accepts the +data only from the genuine My Notes app (checked by its signing certificate). The temporary copy +Encly makes while reading it is deleted right away; the imported notes are stored encrypted like +everything else. Nothing is sent over the network, and Encly sends nothing back to My Notes +except how many items were imported. Attachments and images stay in My Notes. + ## Deleting your data Uninstalling Encly, or clearing its storage in Android settings, permanently deletes all of diff --git a/SECURITY.md b/SECURITY.md index f7816e1..13c426c 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -254,6 +254,31 @@ fileKey = HKDF-SHA256(ikm = backupRoot, salt = <32 random bytes per file>, in messages (which can quote row content). Derived keys and the plaintext payload buffer are zeroized after use. +## Import from My Notes + +Encly is the successor of My Notes, which can hand its data over once +(pasichDev/Encly#46). One way only: Encly never sends anything to My Notes except the result +counts, and exposes no provider or anything readable. + +- `ImportFromMyNotesActivity` is exported for the action + `com.pasich.encly.action.IMPORT_FROM_MY_NOTES` only. Before it looks at the intent's data it + requires `getCallingPackage() == "com.pasich.mynotes"` (set by the system for + `startActivityForResult`) **and** a signing certificate from a pinned SHA-256 set + (`MyNotesCallerVerifier.TRUSTED_MY_NOTES_CERT_SHA256`): `hasSigningCertificate(…, + CERT_INPUT_SHA256)` on Android 9+, and on Android 8.x `GET_SIGNATURES` with exactly one signer. + Anything else is refused (`untrusted_caller`) without reading the URI. +- The vault must be unlocked through the normal lock screen first; an open session (within the + auto-lock grace) is used as is. Backgrounding locks it as everywhere else. +- My Notes' ZIP (plaintext) is copied to Encly's private cache, because the URI grant ends with + the activity, then read and deleted in a `finally`; leftovers of a killed process are deleted + the next time. It is never logged. Reading is strict: one `handoff.json` entry, `format` and + `schema` checked first (a newer schema asks for an Encly update), at most 32 MiB compressed and + 32 MiB uncompressed (counted on the bytes read, so a ZIP bomb stops there), 100 000 records + per list, 1 Mi characters per text field. Editor.js HTML is reduced to plain text. +- Only counts are shown; nothing is written before the user confirms. The data then goes through + the backup merge import: one transaction, records whose uid exists are skipped (a repeated + hand-off adds nothing), tags are matched by name. + ## Session lifecycle - The database is not considered committed until mandatory PIN setup succeeds. diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index a3cf5dc..4aea2d1 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -19,6 +19,8 @@ + + + + + + + + + + + + + + + + + setResult( + RESULT_OK, + Intent() + .putExtra(EXTRA_NOTES, step.summary.notesAdded) + .putExtra(EXTRA_TASKS, step.summary.tasksAdded) + .putExtra(EXTRA_TAGS, step.summary.tagsAdded) + .putExtra(EXTRA_SKIPPED, step.skipped), + ) + + is HandoffStep.Failed -> setResult(RESULT_CANCELED, reasonIntent(step.error)) + + else -> Unit + } + } + + private fun finishWith(error: HandoffError) { + setResult(RESULT_CANCELED, reasonIntent(error)) + finish() + } + + private fun reasonIntent(error: HandoffError) = Intent().putExtra(EXTRA_REASON, error.reason) + + companion object { + const val ACTION_IMPORT_FROM_MY_NOTES = "com.pasich.encly.action.IMPORT_FROM_MY_NOTES" + const val EXTRA_NOTES = "notes" + const val EXTRA_TASKS = "tasks" + const val EXTRA_TAGS = "tags" + const val EXTRA_SKIPPED = "skipped" + const val EXTRA_REASON = "reason" + } +} diff --git a/app/src/main/java/com/pasich/encly/MainActivity.kt b/app/src/main/java/com/pasich/encly/MainActivity.kt index 2c42f89..5bec0d9 100644 --- a/app/src/main/java/com/pasich/encly/MainActivity.kt +++ b/app/src/main/java/com/pasich/encly/MainActivity.kt @@ -1,5 +1,6 @@ package com.pasich.encly +import android.app.Activity import android.content.Intent import android.os.Build import android.os.Bundle @@ -147,20 +148,21 @@ class MainActivity : AppCompatActivity() { stripNavigationExtras(intent) super.onNewIntent(intent) } +} - /** - * Window-level privacy: no autofill service sees any field (notes are not form data), the - * content is marked sensitive for accessibility services that are not accessibility tools - * (TalkBack still reads it), and other apps' overlays are hidden while Encly is in front. - */ - private fun protectWindow() { - window.decorView.importantForAutofill = View.IMPORTANT_FOR_AUTOFILL_NO_EXCLUDE_DESCENDANTS - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { - window.decorView.setAccessibilityDataSensitive(View.ACCESSIBILITY_DATA_SENSITIVE_YES) - } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - window.setHideOverlayWindows(true) - } +/** + * Window-level privacy: no autofill service sees any field (notes are not form data), the + * content is marked sensitive for accessibility services that are not accessibility tools + * (TalkBack still reads it), and other apps' overlays are hidden while Encly is in front. + * Every activity calls it after super.onCreate, next to FLAG_SECURE set before it. + */ +internal fun Activity.protectWindow() { + window.decorView.importantForAutofill = View.IMPORTANT_FOR_AUTOFILL_NO_EXCLUDE_DESCENDANTS + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + window.decorView.setAccessibilityDataSensitive(View.ACCESSIBILITY_DATA_SENSITIVE_YES) + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + window.setHideOverlayWindows(true) } } diff --git a/app/src/main/java/com/pasich/encly/data/backup/BackupImporter.kt b/app/src/main/java/com/pasich/encly/data/backup/BackupImporter.kt index 5ab4de7..a4e45aa 100644 --- a/app/src/main/java/com/pasich/encly/data/backup/BackupImporter.kt +++ b/app/src/main/java/com/pasich/encly/data/backup/BackupImporter.kt @@ -10,6 +10,7 @@ import com.pasich.encly.data.model.Note import com.pasich.encly.data.model.Subtask import com.pasich.encly.data.model.Tag import com.pasich.encly.data.model.Task +import java.util.Locale enum class ImportMode { /** @@ -22,6 +23,19 @@ enum class ImportMode { REPLACE, } +/** How a backup tag finds the vault tag it is. */ +enum class TagMatch { + /** Only by uid: a tag with an unknown uid is added, even if one with its name exists. */ + UID, + + /** + * By uid, else by name (trimmed, case-insensitive): a tag whose name the vault already has + * reuses that tag instead of adding a second one with the same name. For sources whose tags + * are just names, such as the My Notes hand-off. + */ + UID_OR_NAME, +} + /** [skipped] counts notes, tags and tasks; sub-tasks are reported on their own. */ data class ImportSummary( val notesAdded: Int, @@ -77,7 +91,12 @@ object BackupMapper { */ object BackupImporter { - suspend fun import(payload: BackupPayload, mode: ImportMode, store: VaultDataStore): ImportSummary { + suspend fun import( + payload: BackupPayload, + mode: ImportMode, + store: VaultDataStore, + tagMatch: TagMatch = TagMatch.UID, + ): ImportSummary { BackupPayloadCodec.validate(payload) return store.inTransaction { val existing = if (mode == ImportMode.REPLACE) { @@ -87,7 +106,7 @@ object BackupImporter { store.snapshot() } val tagIds = existing.tags.associate { it.uid to it.id }.toMutableMap() - val tagsAdded = importTags(payload.tags, existing, mode, tagIds, store) + val tagsAdded = importTags(payload.tags, existing, TagTarget(mode, tagMatch, tagIds), store) val noteUids = existing.notes.mapTo(HashSet()) { it.uid } val newNotes = payload.notes.filter { it.uid !in noteUids } @@ -115,27 +134,41 @@ object BackupImporter { } } - /** Adds missing tags and fills [tagIds] (uid -> local id) for every backup tag. */ + /** [tagIds]: uid -> local id, filled in for every backup tag. */ + private class TagTarget(val mode: ImportMode, val match: TagMatch, val tagIds: MutableMap) + + /** + * Adds missing tags and fills [TagTarget.tagIds] for every backup tag. A tag matched by name + * ([TagMatch.UID_OR_NAME]) is not added; it counts as skipped, like a uid match. + */ private suspend fun importTags( tags: List, existing: VaultSnapshot, - mode: ImportMode, - tagIds: MutableMap, + target: TagTarget, store: VaultDataStore, ): Int { + val merge = target.mode == ImportMode.MERGE // Merged tags go after the ones already on this device, in their backup order. - var nextPosition = if (mode == ImportMode.MERGE) { - existing.tags.maxOfOrNull { it.position + 1 } ?: 0 - } else { - 0 + var nextPosition = if (merge) existing.tags.maxOfOrNull { it.position + 1 } ?: 0 else 0 + // Name -> local id; the first tag with a name wins, also for tags added below. + val idsByName = HashMap() + if (target.match == TagMatch.UID_OR_NAME) { + existing.tags.forEach { idsByName.putIfAbsent(tagNameKey(it.nameTag), it.id) } } var added = 0 tags.sortedBy { it.position }.forEach { tag -> - if (tag.uid in tagIds) return@forEach - val position = if (mode == ImportMode.MERGE) nextPosition++ else tag.position - tagIds[tag.uid] = store.insertTag( + if (tag.uid in target.tagIds) return@forEach + val sameName = if (target.match == TagMatch.UID_OR_NAME) idsByName[tagNameKey(tag.name)] else null + if (sameName != null) { + target.tagIds[tag.uid] = sameName + return@forEach + } + val position = if (merge) nextPosition++ else tag.position + val id = store.insertTag( Tag(nameTag = tag.name, isVisible = tag.visible, position = position, uid = tag.uid), ) + target.tagIds[tag.uid] = id + if (target.match == TagMatch.UID_OR_NAME) idsByName.putIfAbsent(tagNameKey(tag.name), id) added++ } return added @@ -177,6 +210,9 @@ object BackupImporter { uid = uid, ) + /** Two tag names are the same tag for [TagMatch.UID_OR_NAME] when their keys are equal. */ + fun tagNameKey(name: String): String = name.trim().lowercase(Locale.ROOT) + private fun BackupNote.toEntity(tagIds: Map) = Note( title = title, value = value, diff --git a/app/src/main/java/com/pasich/encly/data/backup/BackupManager.kt b/app/src/main/java/com/pasich/encly/data/backup/BackupManager.kt index 190e901..184754a 100644 --- a/app/src/main/java/com/pasich/encly/data/backup/BackupManager.kt +++ b/app/src/main/java/com/pasich/encly/data/backup/BackupManager.kt @@ -78,8 +78,8 @@ class BackupManager @Inject constructor( fun normalizeRecoveryPhrase(input: CharArray): CharArray = BackupKeys.normalizeMnemonic(input) /** Imports into the unlocked vault in one transaction; nothing is written on failure. */ - suspend fun import(payload: BackupPayload, mode: ImportMode): ImportSummary = - guarded { BackupImporter.import(payload, mode, store) } + suspend fun import(payload: BackupPayload, mode: ImportMode, tagMatch: TagMatch = TagMatch.UID): ImportSummary = + guarded { BackupImporter.import(payload, mode, store, tagMatch) } fun lastExportAt(): Long? = secureStoragePrefs.getLong(LAST_EXPORT_KEY, 0L).takeIf { it > 0L } diff --git a/app/src/main/java/com/pasich/encly/data/handoff/EditorJson.kt b/app/src/main/java/com/pasich/encly/data/handoff/EditorJson.kt new file mode 100644 index 0000000..8f58a78 --- /dev/null +++ b/app/src/main/java/com/pasich/encly/data/handoff/EditorJson.kt @@ -0,0 +1,112 @@ +package com.pasich.encly.data.handoff + +import com.pasich.encly.domain.model.ItemListBlock +import com.pasich.encly.dynamicBlocks.Block +import com.pasich.encly.dynamicBlocks.BlockType +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.serialization.SerializationException +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.contentOrNull + +/** Reading an Editor.js document defensively: any shape it does not expect is "nothing". */ +internal object EditorJson { + private val json = Json { isLenient = false } + + /** Far deeper than any document the editor writes (a nested list adds two levels per level). */ + private const val MAX_JSON_DEPTH = 128 + + /** The block array of an Editor.js document (a bare array, or an object with `blocks`). */ + fun blocks(valueJson: String): List? { + // The JSON tree parser recurses per level: a hostile document could overflow the stack. + val root = if (nestingDepth(valueJson) > MAX_JSON_DEPTH) null else parse(valueJson) + val blocks = when (root) { + is JsonArray -> root + is JsonObject -> root["blocks"] as? JsonArray + else -> null + } + return blocks?.filterIsInstance() + } + + fun string(element: JsonElement?): String? = (element as? JsonPrimitive)?.takeIf { it.isString }?.contentOrNull + + fun boolean(element: JsonElement?): Boolean? = (element as? JsonPrimitive)?.booleanOrNull + + private fun parse(valueJson: String): JsonElement? = try { + json.parseToJsonElement(valueJson) + } catch (_: SerializationException) { + null + } + + /** The deepest `[` / `{` nesting in [text], brackets inside strings not counted. */ + private fun nestingDepth(text: String): Int { + var depth = 0 + var deepest = 0 + var inString = false + var escaped = false + text.forEach { c -> + when { + escaped -> escaped = false + inString && c == '\\' -> escaped = true + c == '"' -> inString = !inString + inString -> Unit + c == '[' || c == '{' -> deepest = maxOf(deepest, ++depth) + c == ']' || c == '}' -> depth-- + } + } + return deepest + } +} + +/** Editor.js `list` (1.x strings, 2.x items with nesting and checklist style) and `checklist`. */ +internal object EditorJsLists { + private const val MAX_LIST_DEPTH = 32 + + fun type(style: String?): BlockType = when (style) { + "ordered" -> BlockType.LIST_NUMBER + "checklist" -> BlockType.LIST_CHECK + else -> BlockType.LIST_BULLET + } + + /** The list block for [data], or null when it has no non-blank item. */ + fun block(data: JsonObject, type: BlockType): Block? { + val items = mutableListOf() + flattenItems(data["items"] as? JsonArray, items, depth = 0) + if (items.isEmpty()) return null + val kept = if (type == BlockType.LIST_CHECK) items else items.map { it.copy(isCheck = false) } + return Block.ListBlock(items = MutableStateFlow(kept), blockType = type) + } + + /** + * List items in reading order, nested ones after their parent. An item is a string (the + * older list tool), `{content, meta: {checked}, items}` (list 2.x) or `{text, checked}` + * (the checklist tool). Nesting deeper than [MAX_LIST_DEPTH] is dropped. + */ + private fun flattenItems(items: JsonArray?, into: MutableList, depth: Int) { + if (depth > MAX_LIST_DEPTH) return + items?.forEach { item -> + when (item) { + is JsonPrimitive -> addItem(into, EditorJson.string(item), checked = false) + + is JsonObject -> { + val meta = item["meta"] as? JsonObject + val checked = EditorJson.boolean(item["checked"]) ?: EditorJson.boolean(meta?.get("checked")) + val html = EditorJson.string(item["content"]) ?: EditorJson.string(item["text"]) + addItem(into, html, checked == true) + flattenItems(item["items"] as? JsonArray, into, depth + 1) + } + + else -> Unit + } + } + } + + private fun addItem(into: MutableList, html: String?, checked: Boolean) { + val value = html?.let(InlineHtml::toPlainText)?.trim() + if (!value.isNullOrBlank()) into += ItemListBlock(value = value, isCheck = checked) + } +} diff --git a/app/src/main/java/com/pasich/encly/data/handoff/HandoffStaging.kt b/app/src/main/java/com/pasich/encly/data/handoff/HandoffStaging.kt new file mode 100644 index 0000000..1be3074 --- /dev/null +++ b/app/src/main/java/com/pasich/encly/data/handoff/HandoffStaging.kt @@ -0,0 +1,43 @@ +package com.pasich.encly.data.handoff + +import java.io.File +import java.io.IOException +import java.io.InputStream + +/** + * The one place a hand-off touches the disk: My Notes' ZIP is copied into [dir] (the app-private + * cache) because the URI grant ends with the activity and the reader needs + * random access, then parsed and mapped, then deleted in a `finally`. [clear] also removes + * what a killed process left behind. The plaintext lives on disk only for the parse. + * + * Deleting is all that can be done: on flash storage overwriting a file does not reliably + * erase the old blocks, so it is not attempted. + */ +class HandoffStaging(private val dir: File, private val limits: HandoffLimits = HandoffLimits()) { + + /** + * Copies what [open] returns, reads and maps it. Throws [HandoffException]; an [IOException] + * or [SecurityException] from [open] or the copy means the URI could not be read. + */ + fun load(open: () -> InputStream?): HandoffImport { + clear() + try { + if (!dir.mkdirs() && !dir.isDirectory) throw IOException("no staging directory") + val zip = File(dir, ZIP_NAME) + val input = open() ?: throw HandoffException(HandoffError.INVALID_PAYLOAD) + input.use { source -> zip.outputStream().use { MyNotesHandoffReader.copyLimited(source, it, limits) } } + return MyNotesHandoffMapper.map(MyNotesHandoffReader.read(zip, limits)) + } finally { + clear() + } + } + + fun clear() { + dir.deleteRecursively() + } + + companion object { + const val DIR_NAME = "mynotes-handoff" + private const val ZIP_NAME = "handoff.zip" + } +} diff --git a/app/src/main/java/com/pasich/encly/data/handoff/InlineHtml.kt b/app/src/main/java/com/pasich/encly/data/handoff/InlineHtml.kt new file mode 100644 index 0000000..8c45b37 --- /dev/null +++ b/app/src/main/java/com/pasich/encly/data/handoff/InlineHtml.kt @@ -0,0 +1,57 @@ +package com.pasich.encly.data.handoff + +/** + * Editor.js stores inline formatting as HTML inside a block's text (``, ``, ``, + * ``, ``, `
`, entities). Encly blocks are plain text, so the formatting is dropped + * and only what the user typed is kept: no raw markup reaches the vault. + */ +object InlineHtml { + private val LINE_BREAK = Regex("""""", RegexOption.IGNORE_CASE) + private val TAG = Regex("""]*>""") + private val ENTITY = Regex("""&(#[0-9]{1,7}|#[xX][0-9A-Fa-f]{1,6}|[A-Za-z]{2,8});""") + private const val HEX = 16 + + // As char codes, not literals: the characters are invisible in source. + private const val NO_BREAK_SPACE_CODE = 0x00A0 + private const val ZERO_WIDTH_SPACE_CODE = 0x200B + private const val BYTE_ORDER_MARK_CODE = 0xFEFF + private val NO_BREAK_SPACE = Char(NO_BREAK_SPACE_CODE) + private val ZERO_WIDTH_SPACE = Char(ZERO_WIDTH_SPACE_CODE) + private val BYTE_ORDER_MARK = Char(BYTE_ORDER_MARK_CODE) + + private val NAMED = mapOf( + "amp" to "&", + "lt" to "<", + "gt" to ">", + "quot" to "\"", + "apos" to "'", + "nbsp" to " ", + ) + + /** + * [html] as plain text: `
` becomes a line break, every other tag is removed, entities + * are decoded once (so a typed "<b>" stays the text ""), and the invisible + * characters the editor leaves behind are dropped. Not trimmed. + */ + fun toPlainText(html: String): String { + val withoutTags = TAG.replace(LINE_BREAK.replace(html, "\n"), "") + return ENTITY.replace(withoutTags) { decode(it.groupValues[1]) ?: it.value } + .replace(NO_BREAK_SPACE, ' ') + .replace(ZERO_WIDTH_SPACE.toString(), "") + .replace(BYTE_ORDER_MARK.toString(), "") + } + + private fun decode(entity: String): String? { + val codePoint = when { + entity.startsWith("#x", ignoreCase = true) -> entity.substring(2).toIntOrNull(HEX) + entity.startsWith("#") -> entity.substring(1).toIntOrNull() + else -> return NAMED[entity] + } + return codePoint?.takeIf(::isTextCodePoint)?.let { String(Character.toChars(it)) } + } + + /** NUL and lone surrogates are not text; such an entity is kept as typed. */ + private fun isTextCodePoint(codePoint: Int): Boolean = Character.isValidCodePoint(codePoint) && + codePoint != 0 && + codePoint !in Char.MIN_SURROGATE.code..Char.MAX_SURROGATE.code +} diff --git a/app/src/main/java/com/pasich/encly/data/handoff/MyNotesBlockMapper.kt b/app/src/main/java/com/pasich/encly/data/handoff/MyNotesBlockMapper.kt new file mode 100644 index 0000000..e2edc4c --- /dev/null +++ b/app/src/main/java/com/pasich/encly/data/handoff/MyNotesBlockMapper.kt @@ -0,0 +1,67 @@ +package com.pasich.encly.data.handoff + +import com.pasich.encly.core.serialization.BlockConverter +import com.pasich.encly.dynamicBlocks.Block +import com.pasich.encly.dynamicBlocks.BlockType +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.intOrNull + +/** + * A My Notes note's content as Encly blocks, serialized the way the notes table stores them. + * + * - Plain note (no `valueJson`): one TEXT block per paragraph, paragraphs being separated by a + * blank line; single line breaks stay inside their paragraph. + * - Editor.js: `paragraph` -> TEXT; `header` / `Headers` level 1-4 -> H1-H4 (5 and 6 -> H4, + * missing -> H2, the tool's default); `list` -> LIST_BULLET, LIST_NUMBER or LIST_CHECK by its + * style, nested items flattened in reading order; `checklist` -> LIST_CHECK; `delimiter` -> + * SEPARATOR; `spacer` dropped; `image` and `attaches` dropped (the files stay in My Notes and + * the hand-off counts them). Any other tool with a `data.text` keeps that text as TEXT. + * - Inline HTML becomes plain text ([InlineHtml]). + * + * `valueJson` that is not a readable Editor.js document falls back to the plain text, so a + * damaged note still comes over as text instead of failing the whole hand-off. + */ +object MyNotesBlockMapper { + private val PARAGRAPH_BREAK = Regex("""\r?\n[ \t]*\r?\n\s*""") + private val HEADINGS = listOf(BlockType.H1, BlockType.H2, BlockType.H3, BlockType.H4) + private const val DEFAULT_HEADING = 2 + + fun toBlocksJson(note: HandoffNote): String = BlockConverter.blocksToJson(toBlocks(note)) + + fun toBlocks(note: HandoffNote): List { + val editorBlocks = note.valueJson?.takeIf { it.isNotBlank() }?.let(EditorJson::blocks) + return editorBlocks?.mapNotNull(::toBlock) ?: plainBlocks(note.value) + } + + private fun plainBlocks(value: String): List = value.split(PARAGRAPH_BREAK) + .map { it.trimEnd() } + .filter { it.isNotBlank() } + .map(::text) + + private fun toBlock(block: JsonObject): Block? { + val data = block["data"] as? JsonObject ?: JsonObject(emptyMap()) + return when (EditorJson.string(block["type"])) { + "paragraph" -> textOf(data)?.let(::text) + "header", "Headers" -> textOf(data)?.let { heading(it, (data["level"] as? JsonPrimitive)?.intOrNull) } + "list" -> EditorJsLists.block(data, EditorJsLists.type(EditorJson.string(data["style"]))) + "checklist" -> EditorJsLists.block(data, BlockType.LIST_CHECK) + "delimiter" -> Block.SeparatorBlock() + "spacer", "image", "attaches" -> null + else -> textOf(data)?.let(::text) + } + } + + /** Levels 5 and 6 become H4; a missing level is the tool's default, 2. */ + private fun heading(text: String, level: Int?): Block { + val type = HEADINGS[(level ?: DEFAULT_HEADING).coerceIn(1, HEADINGS.size) - 1] + return Block.HBlock(text = MutableStateFlow(text), blockType = type) + } + + private fun text(value: String): Block = Block.TextBlock(text = MutableStateFlow(value)) + + /** The block's `data.text` as plain text, or null when it has none. */ + private fun textOf(data: JsonObject): String? = + EditorJson.string(data["text"])?.let(InlineHtml::toPlainText)?.trim()?.takeIf { it.isNotBlank() } +} diff --git a/app/src/main/java/com/pasich/encly/data/handoff/MyNotesCallerVerifier.kt b/app/src/main/java/com/pasich/encly/data/handoff/MyNotesCallerVerifier.kt new file mode 100644 index 0000000..fc59fa4 --- /dev/null +++ b/app/src/main/java/com/pasich/encly/data/handoff/MyNotesCallerVerifier.kt @@ -0,0 +1,87 @@ +package com.pasich.encly.data.handoff + +import android.annotation.SuppressLint +import android.content.pm.PackageManager +import android.os.Build +import java.security.MessageDigest + +/** The package-manager queries [MyNotesCallerVerifier] needs; replaced by a fake in tests. */ +interface PackageSignatures { + val sdkInt: Int + + /** API 28+: whether [packageName] is (or, through key rotation, was) signed by [sha256]. */ + fun hasSigningCertificateSha256(packageName: String, sha256: ByteArray): Boolean + + /** API 26-27: the encoded signing certificates of [packageName]; null if not installed. */ + fun legacySignatures(packageName: String): List? +} + +class AndroidPackageSignatures(private val packageManager: PackageManager) : PackageSignatures { + override val sdkInt: Int = Build.VERSION.SDK_INT + + override fun hasSigningCertificateSha256(packageName: String, sha256: ByteArray): Boolean = + Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && + packageManager.hasSigningCertificate(packageName, sha256, PackageManager.CERT_INPUT_SHA256) + + // GET_SIGNATURES is the only signer API before 28. The lint warning is about trusting one + // of several signers; the verifier accepts exactly one signer, so there is no other to miss. + @SuppressLint("PackageManagerGetSignatures") + @Suppress("DEPRECATION") + override fun legacySignatures(packageName: String): List? = try { + packageManager.getPackageInfo(packageName, PackageManager.GET_SIGNATURES) + .signatures + ?.map { it.toByteArray() } + } catch (_: PackageManager.NameNotFoundException) { + null + } +} + +/** + * Decides whether the activity that started the hand-off is My Notes. Checked before the URI + * is read: anything else is refused without touching its data. + * + * The calling package comes from `Activity.getCallingPackage()`, which the system fills in for + * `startActivityForResult` and a caller cannot forge; the signing certificate then proves that + * package is the real My Notes and not an app installed under its name. + */ +class MyNotesCallerVerifier( + private val signatures: PackageSignatures, + private val trustedCertSha256: Set = TRUSTED_MY_NOTES_CERT_SHA256, +) { + fun isTrusted(callingPackage: String?): Boolean = + callingPackage == MY_NOTES_PACKAGE && hasPinnedCertificate(trustedCertSha256.map(::hexToBytes)) + + private fun hasPinnedCertificate(pinned: List): Boolean = + if (signatures.sdkInt >= Build.VERSION_CODES.P) { + pinned.any { signatures.hasSigningCertificateSha256(MY_NOTES_PACKAGE, it) } + } else { + // Exactly one signer: with several, one trusted certificate would not prove the rest. + val signer = signatures.legacySignatures(MY_NOTES_PACKAGE)?.singleOrNull() + val digest = signer?.let { MessageDigest.getInstance("SHA-256").digest(it) } + digest != null && pinned.any { MessageDigest.isEqual(it, digest) } + } + + private fun hexToBytes(hex: String): ByteArray = ByteArray(hex.length / 2) { i -> + hex.substring(i * 2, i * 2 + 2).toInt(HEX).toByte() + } + + companion object { + const val MY_NOTES_PACKAGE = "com.pasich.mynotes" + private const val HEX = 16 + + /** + * SHA-256 (lower-case hex) of every certificate My Notes is signed with. A caller signed + * with none of them is refused. + * + * - `03f2b8c7…483f`: the release key of the GitHub / F-Droid builds (checked on the + * GitHub release APK 2.6.55, CN=Andrii Pasichnik12). + * + * TODO(pasichDev/Encly#46): before release, add the Google Play App Signing certificate + * SHA-256 of My Notes (Play Console -> My Notes -> Test and release -> App integrity -> + * App signing). Without it the Play build of My Notes is refused as untrusted_caller. + */ + val TRUSTED_MY_NOTES_CERT_SHA256: Set = setOf( + "03f2b8c7c96778b7efb80bb08af99b273a6c0c24e5864d0a211c3a8e3d02483f", + ) + } +} diff --git a/app/src/main/java/com/pasich/encly/data/handoff/MyNotesHandoff.kt b/app/src/main/java/com/pasich/encly/data/handoff/MyNotesHandoff.kt new file mode 100644 index 0000000..b902985 --- /dev/null +++ b/app/src/main/java/com/pasich/encly/data/handoff/MyNotesHandoff.kt @@ -0,0 +1,77 @@ +package com.pasich.encly.data.handoff + +import kotlinx.serialization.Serializable + +/** + * `handoff.json` from My Notes, contract v1 (pasichDev/Encly#46, pasichDev/MyNotes#167). Raw My + * Notes data: Encly does all the mapping ([MyNotesHandoffMapper]), so its block format stays + * private to Encly. + * + * Unknown keys are ignored inside schema 1. A nullable field may also be left out (a Java + * serializer drops nulls by default); every other field is required. + */ +@Serializable +data class MyNotesHandoff( + val format: String, + val schema: Int, + val exportedAt: Long, + val tags: List, + val notes: List, + val taskCategories: List, + val tasks: List, +) { + companion object { + const val FORMAT = "mynotes-handoff" + const val SCHEMA = 1 + } +} + +/** A user tag. Notes refer to it by [name]. */ +@Serializable +data class HandoffTag(val id: String, val name: String, val position: Int) + +/** + * [value] is the plain text; [valueJson], when present, the Editor.js block array of an + * extended note and the richer source. [tag] is a tag *name*. [attachments] counts the files + * that were not sent and stay in My Notes. + */ +@Serializable +data class HandoffNote( + val id: String, + val title: String, + val value: String, + val valueJson: String? = null, + val date: Long, + val tag: String? = null, + val isTrash: Boolean, + val isPinned: Boolean, + val attachments: Int, +) + +/** A task category; it becomes an Encly tag. */ +@Serializable +data class HandoffTaskCategory(val id: String, val name: String, val position: Int) + +@Serializable +data class HandoffTask( + val id: String, + val description: String, + val isDone: Boolean, + val createdAt: Long, + val categoryId: String? = null, + val position: Int, +) + +/** Why a hand-off was refused. [reason] is the contract's `reason` result extra. */ +enum class HandoffError(val reason: String) { + UNTRUSTED_CALLER("untrusted_caller"), + CANCELLED("cancelled"), + + /** A newer `schema`: intact, but this Encly cannot read it ("update Encly"). */ + UNSUPPORTED_SCHEMA("unsupported_schema"), + INVALID_PAYLOAD("invalid_payload"), + TOO_LARGE("too_large"), + FAILED("failed"), +} + +class HandoffException(val error: HandoffError, cause: Throwable? = null) : Exception(error.reason, cause) diff --git a/app/src/main/java/com/pasich/encly/data/handoff/MyNotesHandoffMapper.kt b/app/src/main/java/com/pasich/encly/data/handoff/MyNotesHandoffMapper.kt new file mode 100644 index 0000000..012e7f6 --- /dev/null +++ b/app/src/main/java/com/pasich/encly/data/handoff/MyNotesHandoffMapper.kt @@ -0,0 +1,143 @@ +package com.pasich.encly.data.handoff + +import com.pasich.encly.core.backup.BackupNote +import com.pasich.encly.core.backup.BackupPayload +import com.pasich.encly.core.backup.BackupPayloadCodec +import com.pasich.encly.core.backup.BackupTag +import com.pasich.encly.core.backup.BackupTask +import com.pasich.encly.data.backup.BackupImporter +import java.security.MessageDigest + +/** What the user confirms before the import: counts only, never content. */ +data class HandoffPreview( + val notes: Int, + val tasks: Int, + val tags: Int, + /** Attachment files that were not sent and stay in My Notes. */ + val attachments: Int, + /** Pinned notes: Encly has no pinning, so they arrive unpinned. */ + val pinned: Int, +) + +/** A hand-off mapped for [BackupImporter]; [dropped] records (blank tasks) are not in [payload]. */ +class HandoffImport(val payload: BackupPayload, val preview: HandoffPreview, val dropped: Int) + +/** + * My Notes hand-off -> the [BackupPayload] the backup MERGE import applies, so a hand-off gets + * the same single transaction and uid skip as a backup: a repeated hand-off adds nothing. + * + * - uids: the hand-off's stable ids, which fit Encly's uid limit; a longer one becomes its + * SHA-256 (same id, same uid, so repeats are still skipped). + * - Tags and task categories both become Encly tags. Within the hand-off they are merged by + * name, and the import matches them against the vault's tags by name too + * ([com.pasich.encly.data.backup.TagMatch.UID_OR_NAME]), so no second tag with a name the + * user already has appears. A blank name is no tag; a note tag naming no user tag is dropped. + * - A task's text: the first line is the Encly title, the rest (if any) its description. A task + * with no text is dropped. Done tasks get their creation time as completion time. + * - `isPinned` has no Encly counterpart and is dropped (the preview says how many). + */ +object MyNotesHandoffMapper { + private const val HEX_DIGITS = "0123456789abcdef" + private const val NIBBLE = 4 + private const val LOW_NIBBLE = 0x0f + + fun map(handoff: MyNotesHandoff): HandoffImport { + val tags = HandoffTags(handoff) + val notes = handoff.notes.map { note -> + BackupNote( + uid = uidFor("note", note.id), + title = note.title.trim(), + value = MyNotesBlockMapper.toBlocksJson(note), + description = "", + date = note.date, + dateCreate = note.date, + tagUid = note.tag?.let(tags::uidForName), + isTrash = note.isTrash, + ) + } + val tasks = handoff.tasks.mapNotNull { task -> mapTask(task, tags) } + val payload = BackupPayload( + exportedAt = handoff.exportedAt, + tags = tags.backupTags, + notes = notes, + tasks = tasks, + ) + BackupPayloadCodec.validate(payload) + val preview = HandoffPreview( + notes = notes.size, + tasks = tasks.size, + tags = tags.backupTags.size, + attachments = handoff.notes.sumOf { it.attachments.toLong() }.coerceAtMost(Int.MAX_VALUE.toLong()) + .toInt(), + pinned = handoff.notes.count { it.isPinned }, + ) + return HandoffImport(payload, preview, dropped = handoff.tasks.size - tasks.size) + } + + private fun mapTask(task: HandoffTask, tags: HandoffTags): BackupTask? { + val text = task.description.trim() + if (text.isEmpty()) return null + val title = text.substringBefore('\n').trim() + val rest = text.substringAfter('\n', missingDelimiterValue = "").trim() + return BackupTask( + uid = uidFor("task", task.id), + title = title, + description = rest.ifEmpty { null }, + isCompleted = task.isDone, + createdDate = task.createdAt, + completedDate = if (task.isDone) task.createdAt else null, + priority = 0, + categoryTagUid = task.categoryId?.let(tags::uidForCategory), + position = task.position, + // My Notes tasks have no checklist. + subtasks = emptyList(), + ) + } + + /** The hand-off id as an Encly uid: as is when it fits, else a stable digest of it. */ + internal fun uidFor(kind: String, id: String): String = + if (id.isNotBlank() && id.length <= BackupPayloadCodec.MAX_UID_LENGTH) id else sha256Hex("$kind:$id") + + private fun sha256Hex(text: String): String { + val digest = MessageDigest.getInstance("SHA-256").digest(text.toByteArray(Charsets.UTF_8)) + return buildString(digest.size * 2) { + digest.forEach { byte -> + val b = byte.toInt() + append(HEX_DIGITS[(b shr NIBBLE) and LOW_NIBBLE]) + append(HEX_DIGITS[b and LOW_NIBBLE]) + } + } + } + + /** User tags then task categories, each in its own order, merged into one tag per name. */ + private class HandoffTags(handoff: MyNotesHandoff) { + private val uidByName = LinkedHashMap() + private val uidByCategoryId = HashMap() + private val usedUids = HashSet() + val backupTags = mutableListOf() + + init { + handoff.tags.sortedBy { it.position }.forEach { add("tag", it.id, it.name) } + handoff.taskCategories.sortedBy { it.position }.forEach { category -> + add("category", category.id, category.name)?.let { uidByCategoryId[category.id] = it } + } + } + + fun uidForName(name: String): String? = uidByName[BackupImporter.tagNameKey(name)] + + fun uidForCategory(id: String): String? = uidByCategoryId[id] + + /** The uid of the tag called [rawName], added if it is new; null for a blank name. */ + private fun add(kind: String, id: String, rawName: String): String? { + val name = rawName.trim() + if (name.isEmpty()) return null + return uidByName.getOrPut(BackupImporter.tagNameKey(name)) { + // A tag and a category can share an id (each list is unique only in itself). + val uid = uidFor(kind, id).takeUnless { it in usedUids } ?: sha256Hex("$kind:$id") + usedUids += uid + backupTags += BackupTag(uid = uid, name = name, visible = true, position = backupTags.size) + uid + } + } + } +} diff --git a/app/src/main/java/com/pasich/encly/data/handoff/MyNotesHandoffReader.kt b/app/src/main/java/com/pasich/encly/data/handoff/MyNotesHandoffReader.kt new file mode 100644 index 0000000..52c904d --- /dev/null +++ b/app/src/main/java/com/pasich/encly/data/handoff/MyNotesHandoffReader.kt @@ -0,0 +1,165 @@ +package com.pasich.encly.data.handoff + +import kotlinx.serialization.DeserializationStrategy +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.Serializable +import kotlinx.serialization.SerializationException +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.decodeFromStream +import java.io.File +import java.io.FilterInputStream +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream +import java.util.zip.ZipFile + +/** + * Size limits of a hand-off. Every one is enforced on the bytes actually read, never on a + * size a header claims, so a ZIP bomb or an endless stream stops at the limit. + */ +data class HandoffLimits( + /** The ZIP as copied from My Notes. */ + val maxZipBytes: Long = MAX_BYTES, + /** `handoff.json` uncompressed. */ + val maxJsonBytes: Long = MAX_BYTES, + /** Records per list (notes, tasks, tags, task categories). */ + val maxRecords: Int = 100_000, + /** A note's title, text or Editor.js JSON, a task's text: characters each. */ + val maxTextChars: Int = MIB, + /** A tag or category name, and a record id. */ + val maxNameChars: Int = 1_000, +) { + private companion object { + const val MIB = 1024 * 1024 + const val MAX_BYTES = 32L * MIB + } +} + +/** + * Reads a My Notes hand-off ZIP strictly: exactly one entry, `handoff.json`, in the v1 shape, + * within [HandoffLimits]. Throws [HandoffException] with [HandoffError.TOO_LARGE], + * [HandoffError.UNSUPPORTED_SCHEMA] or [HandoffError.INVALID_PAYLOAD]; nothing else escapes. + */ +@OptIn(ExperimentalSerializationApi::class) +object MyNotesHandoffReader { + const val ENTRY_NAME = "handoff.json" + + private val json = Json { + ignoreUnknownKeys = true + isLenient = false + coerceInputValues = false + } + + /** Reads `format` and `schema` only, before the full decode. */ + @Serializable + private class Probe(val format: String? = null, val schema: Int? = null) + + /** + * Copies [input] (the content URI's stream) to [target], stopping with [HandoffError.TOO_LARGE] + * past [HandoffLimits.maxZipBytes]. The caller deletes [target] whatever the outcome. + */ + fun copyLimited(input: InputStream, target: OutputStream, limits: HandoffLimits = HandoffLimits()) { + try { + LimitedInputStream(input, limits.maxZipBytes).copyTo(target) + } catch (_: LimitExceededException) { + throw HandoffException(HandoffError.TOO_LARGE) + } + } + + fun read(zip: File, limits: HandoffLimits = HandoffLimits()): MyNotesHandoff { + ensure(zip.length() <= limits.maxZipBytes, HandoffError.TOO_LARGE) + val handoff = guarded { + ZipFile(zip).use { archive -> + ensure(archive.size() == 1, HandoffError.INVALID_PAYLOAD) + val entry = archive.getEntry(ENTRY_NAME)?.takeUnless { it.isDirectory } + ?: throw HandoffException(HandoffError.INVALID_PAYLOAD) + // A declared size is only a hint (it can lie); the stream limit below is the check. + ensure(entry.size <= limits.maxJsonBytes, HandoffError.TOO_LARGE) + val open = { LimitedInputStream(archive.getInputStream(entry), limits.maxJsonBytes) } + val probe = open().use { decode(Probe.serializer(), it) } + val schema = probe.schema?.takeIf { probe.format == MyNotesHandoff.FORMAT } + ensure(schema != null && schema >= MyNotesHandoff.SCHEMA, HandoffError.INVALID_PAYLOAD) + ensure(schema == MyNotesHandoff.SCHEMA, HandoffError.UNSUPPORTED_SCHEMA) + open().use { decode(MyNotesHandoff.serializer(), it) } + } + } + validate(handoff, limits) + return handoff + } + + private fun decode(deserializer: DeserializationStrategy, input: InputStream): T = + json.decodeFromStream(deserializer, input) + + /** Maps every parse failure to a [HandoffError]; a [HandoffException] passes through. */ + private inline fun guarded(block: () -> T): T = try { + block() + } catch (e: IOException) { + rejected(e) + } catch (e: SerializationException) { + rejected(e) + } catch (e: IllegalArgumentException) { + rejected(e) + } + + /** The cause is dropped on purpose: a parser message can quote note content. */ + private fun rejected(e: Exception): Nothing = throw HandoffException( + if (e is LimitExceededException) HandoffError.TOO_LARGE else HandoffError.INVALID_PAYLOAD, + ) + + private fun validate(handoff: MyNotesHandoff, limits: HandoffLimits) { + val lists = listOf(handoff.tags, handoff.notes, handoff.taskCategories, handoff.tasks) + ensure(lists.all { it.size <= limits.maxRecords }, HandoffError.TOO_LARGE) + val texts = handoff.notes.flatMap { listOf(it.title, it.value, it.valueJson.orEmpty()) } + + handoff.tasks.map { it.description } + ensure(texts.all { it.length <= limits.maxTextChars }, HandoffError.TOO_LARGE) + val names = handoff.tags.flatMap { listOf(it.id, it.name) } + + handoff.taskCategories.flatMap { listOf(it.id, it.name) } + + handoff.notes.flatMap { listOf(it.id, it.tag.orEmpty()) } + + handoff.tasks.flatMap { listOf(it.id, it.categoryId.orEmpty()) } + ensure(names.all { it.length <= limits.maxNameChars }, HandoffError.TOO_LARGE) + + val valid = uniqueIds(handoff.tags.map { it.id }) && + uniqueIds(handoff.notes.map { it.id }) && + uniqueIds(handoff.taskCategories.map { it.id }) && + uniqueIds(handoff.tasks.map { it.id }) && + handoff.notes.all { it.attachments >= 0 } + ensure(valid, HandoffError.INVALID_PAYLOAD) + val categoryIds = handoff.taskCategories.mapTo(HashSet()) { it.id } + val knownCategories = handoff.tasks.all { it.categoryId == null || it.categoryId in categoryIds } + ensure(knownCategories, HandoffError.INVALID_PAYLOAD) + } + + private fun uniqueIds(ids: List): Boolean = ids.all { it.isNotBlank() } && ids.toSet().size == ids.size + + private fun ensure(condition: Boolean, error: HandoffError) { + if (!condition) throw HandoffException(error) + } + + private class LimitExceededException : IOException("limit exceeded") + + /** Fails with [LimitExceededException] once more than [limit] bytes were read. */ + private class LimitedInputStream(input: InputStream, private val limit: Long) : FilterInputStream(input) { + private var count = 0L + + override fun read(): Int { + val b = super.read() + if (b >= 0) count(1) + return b + } + + override fun read(b: ByteArray, off: Int, len: Int): Int { + val n = super.read(b, off, len) + if (n > 0) count(n.toLong()) + return n + } + + override fun skip(n: Long): Long = super.skip(n).also(::count) + + override fun markSupported(): Boolean = false + + private fun count(n: Long) { + count += n + if (count > limit) throw LimitExceededException() + } + } +} diff --git a/app/src/main/java/com/pasich/encly/presentation/screen/LockExits.kt b/app/src/main/java/com/pasich/encly/presentation/screen/LockExits.kt new file mode 100644 index 0000000..c2a4409 --- /dev/null +++ b/app/src/main/java/com/pasich/encly/presentation/screen/LockExits.kt @@ -0,0 +1,14 @@ +package com.pasich.encly.presentation.screen + +/** + * Where the lock screen goes next. [onUnlock] (PIN or fingerprint) and [onRecoveryUnlock] (the + * recovery phrase: the PIN was forgotten) get a check for "the session closed again meanwhile". + * [onVaultLost]: nothing can open the vault on this device any more. [onBack], when set, is where + * Back on the PIN pad goes; without it Back never leaves the lock screen. + */ +class LockExits( + val onUnlock: (isSessionLocked: () -> Boolean) -> Unit, + val onRecoveryUnlock: (isSessionLocked: () -> Boolean) -> Unit, + val onVaultLost: () -> Unit, + val onBack: (() -> Unit)? = null, +) diff --git a/app/src/main/java/com/pasich/encly/presentation/screen/LockScreen.kt b/app/src/main/java/com/pasich/encly/presentation/screen/LockScreen.kt index 6462585..b9d5dae 100644 --- a/app/src/main/java/com/pasich/encly/presentation/screen/LockScreen.kt +++ b/app/src/main/java/com/pasich/encly/presentation/screen/LockScreen.kt @@ -43,36 +43,55 @@ import com.pasich.encly.presentation.viewmodel.PinUnlockResult import com.pasich.encly.presentation.viewmodel.SeedUnlockResult import com.pasich.encly.ui.theme.EnclyTheme +/** The app's lock screen: unlocking opens Home (or the note that was open), see [leaveLockScreen]. */ @Composable fun LockScreen( navController: NavHostController, modifier: Modifier = Modifier, viewModel: LockViewModel = hiltViewModel(), ) { + val unlockReveal = LocalUnlockReveal.current + val exits = remember(navController, unlockReveal) { + LockExits( + onUnlock = { isSessionLocked -> + navController.leaveLockScreen(unlockReveal, isSessionLocked, viewModel::canReopenNote) + }, + // A recovery-phrase unlock means the PIN was forgotten: set a new one before going on. + onRecoveryUnlock = { + navController.navigate(NavRoutes.PinCodeConfig.name) { + popUpTo(NavRoutes.LockRoute.name) { inclusive = true } + } + }, + onVaultLost = { + navController.navigate(NavRoutes.LossDataRoute.name) { + popUpTo(NavRoutes.LockRoute.name) { inclusive = true } + } + }, + ) + } + LockScreen(exits = exits, modifier = modifier, viewModel = viewModel) +} + +@Composable +fun LockScreen(exits: LockExits, modifier: Modifier = Modifier, viewModel: LockViewModel = hiltViewModel()) { val activity = LocalActivity.current as? FragmentActivity val busy by viewModel.busy.collectAsState() // Above the loading view below: the forms' input and errors survive the credential check. val form = remember { LockFormState() } - // Back never leaves the lock screen; from the recovery form it returns to the PIN pad. - BackHandler(enabled = true) { if (!busy) form.back() } + // From the recovery form Back returns to the PIN pad; from the PIN pad it goes to + // [LockExits.onBack], or nowhere: by default Back never leaves the lock screen. + BackHandler(enabled = true) { onLockBack(busy, form, exits.onBack) } val biometricEnabled = remember { viewModel.biometricEnabled() && viewModel.biometricAvailable() } - val unlockReveal = LocalUnlockReveal.current - - fun goHome() = navController.leaveLockScreen(unlockReveal, viewModel::isSessionLocked, viewModel::canReopenNote) + fun goHome() = exits.onUnlock(viewModel::isSessionLocked) - // A recovery-phrase unlock means the PIN was forgotten: set a new one before going on. - fun goToPinReset() { - navController.navigate(NavRoutes.PinCodeConfig.name) { - popUpTo(NavRoutes.LockRoute.name) { inclusive = true } - } - } + fun goToPinReset() = exits.onRecoveryUnlock(viewModel::isSessionLocked) - fun onPinKeyLoss() = navController.onPinKeyLoss(form, viewModel.recoveryAvailable(), biometricEnabled) + fun onPinKeyLoss() = handlePinKeyLoss(form, viewModel.recoveryAvailable(), biometricEnabled, exits.onVaultLost) fun promptBiometric() { if (activity != null && viewModel.lockoutRemainingMillis() <= 0) { @@ -117,6 +136,14 @@ fun LockScreen( } } +private fun onLockBack(busy: Boolean, form: LockFormState, onBack: (() -> Unit)?) { + when { + busy -> Unit + form.useRecovery -> form.back() + else -> onBack?.invoke() + } +} + private data class LockCapabilities(val biometricEnabled: Boolean, val recoveryAvailable: Boolean) /** The LockViewModel state and operations the PIN form needs, so the ViewModel itself stays in [LockScreen]. */ @@ -154,15 +181,18 @@ private fun NavHostController.leaveLockScreen( /** * The PIN can no longer unlock on this device. With a recovery phrase the lock screen switches * to it (the form shows why); with only a fingerprint the PIN pad keeps the message; with - * neither, nothing can open the vault here any more and the damaged-vault screen explains it. + * neither, nothing can open the vault here any more ([onVaultLost]). */ -private fun NavHostController.onPinKeyLoss(form: LockFormState, recoveryAvailable: Boolean, biometric: Boolean) { +private fun handlePinKeyLoss( + form: LockFormState, + recoveryAvailable: Boolean, + biometric: Boolean, + onVaultLost: () -> Unit, +) { if (recoveryAvailable) { form.useRecovery = true } else if (!biometric) { - navigate(NavRoutes.LossDataRoute.name) { - popUpTo(NavRoutes.LockRoute.name) { inclusive = true } - } + onVaultLost() } } diff --git a/app/src/main/java/com/pasich/encly/presentation/screen/handoff/ImportFromMyNotesScreen.kt b/app/src/main/java/com/pasich/encly/presentation/screen/handoff/ImportFromMyNotesScreen.kt new file mode 100644 index 0000000..1af5a82 --- /dev/null +++ b/app/src/main/java/com/pasich/encly/presentation/screen/handoff/ImportFromMyNotesScreen.kt @@ -0,0 +1,170 @@ +package com.pasich.encly.presentation.screen.handoff + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.hilt.navigation.compose.hiltViewModel +import com.pasich.encly.R +import com.pasich.encly.data.handoff.HandoffError +import com.pasich.encly.data.handoff.HandoffPreview +import com.pasich.encly.presentation.designsystem.CalloutTone +import com.pasich.encly.presentation.designsystem.EnclyButton +import com.pasich.encly.presentation.designsystem.EnclyCallout +import com.pasich.encly.presentation.designsystem.EnclyTextButton +import com.pasich.encly.presentation.designsystem.EnclyTopBar +import com.pasich.encly.presentation.designsystem.StepHeading +import com.pasich.encly.presentation.screen.LockExits +import com.pasich.encly.presentation.screen.LockScreen +import com.pasich.encly.presentation.screen.pincode.AuthLoading +import com.pasich.encly.presentation.viewmodel.HandoffStep +import com.pasich.encly.presentation.viewmodel.ImportFromMyNotesViewModel +import com.pasich.encly.ui.theme.EnclyTheme + +/** + * Import from My Notes: the lock screen while the vault is closed, then the hand-off's steps. + * [onClose] ends the activity with the result its current step stands for. + */ +@Composable +fun ImportFromMyNotesScreen( + onClose: () -> Unit, + modifier: Modifier = Modifier, + viewModel: ImportFromMyNotesViewModel = hiltViewModel(), +) { + val locked by viewModel.locked.collectAsState() + val step by viewModel.step.collectAsState() + if (locked) { + // Unlocking publishes itself to the session (LockViewModel); the steps then follow. + val exits = remember(viewModel, onClose) { + LockExits( + onUnlock = {}, + onRecoveryUnlock = {}, + onVaultLost = viewModel::onVaultLost, + onBack = onClose, + ) + } + LockScreen(exits = exits, modifier = modifier) + } else { + HandoffContent(step = step, onConfirm = viewModel::confirm, onClose = onClose, modifier = modifier) + } +} + +@Composable +private fun HandoffContent( + step: HandoffStep, + onConfirm: () -> Unit, + onClose: () -> Unit, + modifier: Modifier = Modifier, +) { + // The import is one transaction; leaving in the middle would only roll it back. + BackHandler(enabled = step == HandoffStep.Importing) {} + when (step) { + HandoffStep.Waiting -> AuthLoading(stringResource(R.string.handoff_title), modifier) + + HandoffStep.Reading -> AuthLoading(stringResource(R.string.handoff_reading), modifier) + + HandoffStep.Importing -> AuthLoading(stringResource(R.string.handoff_importing), modifier) + + is HandoffStep.Preview -> HandoffPage(onClose = onClose, modifier = modifier) { + PreviewBody(step.preview) + EnclyButton(text = stringResource(R.string.handoff_import), onClick = onConfirm) + EnclyTextButton( + text = stringResource(R.string.cancel), + onClick = onClose, + modifier = Modifier.fillMaxWidth(), + ) + } + + is HandoffStep.Done -> HandoffPage(onClose = onClose, modifier = modifier) { + val summary = step.summary + StepHeading( + title = stringResource(R.string.handoff_done_title), + body = stringResource( + R.string.backup_import_done, + summary.notesAdded, + summary.tasksAdded, + summary.tagsAdded, + step.skipped, + ), + ) + EnclyCallout(text = stringResource(R.string.handoff_done_hint)) + EnclyButton(text = stringResource(R.string.done), onClick = onClose) + } + + is HandoffStep.Failed -> HandoffPage(onClose = onClose, modifier = modifier) { + StepHeading(title = stringResource(R.string.handoff_error_title), body = stringResource(errorText(step))) + EnclyButton(text = stringResource(R.string.close), onClick = onClose) + } + } +} + +/** The counts, then what will not come over. */ +@Composable +private fun PreviewBody(preview: HandoffPreview, modifier: Modifier = Modifier) { + Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.section)) { + StepHeading( + title = stringResource(R.string.handoff_preview_title), + body = stringResource( + R.string.handoff_preview_body, + pluralStringResource(R.plurals.backup_count_notes, preview.notes, preview.notes), + pluralStringResource(R.plurals.backup_count_tasks, preview.tasks, preview.tasks), + pluralStringResource(R.plurals.backup_count_tags, preview.tags, preview.tags), + ), + ) + if (preview.attachments > 0) { + EnclyCallout( + text = stringResource(R.string.handoff_preview_attachments, preview.attachments), + tone = CalloutTone.WARNING, + ) + } + if (preview.pinned > 0) { + EnclyCallout(text = stringResource(R.string.handoff_preview_pinned, preview.pinned)) + } + } +} + +/** A titled, scrolling page with a close button, for every step that waits for the user. */ +@Composable +private fun HandoffPage( + onClose: () -> Unit, + modifier: Modifier = Modifier, + content: @Composable ColumnScope.() -> Unit, +) { + Surface(modifier = modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surface) { + Column { + EnclyTopBar(title = stringResource(R.string.handoff_title), onClose = onClose) + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .navigationBarsPadding() + .padding(horizontal = EnclyTheme.spacing.gutter, vertical = EnclyTheme.spacing.s), + verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.section), + content = content, + ) + } + } +} + +private fun errorText(step: HandoffStep.Failed): Int = when { + step.vaultUnavailable -> R.string.handoff_error_vault + step.error == HandoffError.UNSUPPORTED_SCHEMA -> R.string.handoff_error_unsupported + step.error == HandoffError.TOO_LARGE -> R.string.handoff_error_too_large + step.error == HandoffError.INVALID_PAYLOAD -> R.string.handoff_error_invalid + else -> R.string.handoff_error_failed +} diff --git a/app/src/main/java/com/pasich/encly/presentation/viewmodel/ImportFromMyNotesViewModel.kt b/app/src/main/java/com/pasich/encly/presentation/viewmodel/ImportFromMyNotesViewModel.kt new file mode 100644 index 0000000..8decbdc --- /dev/null +++ b/app/src/main/java/com/pasich/encly/presentation/viewmodel/ImportFromMyNotesViewModel.kt @@ -0,0 +1,151 @@ +package com.pasich.encly.presentation.viewmodel + +import android.content.Context +import android.net.Uri +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.pasich.encly.core.backup.BackupException +import com.pasich.encly.core.di.IoDispatcher +import com.pasich.encly.core.security.InitialStatus +import com.pasich.encly.core.security.SecurityManager +import com.pasich.encly.core.security.SessionLockManager +import com.pasich.encly.data.backup.BackupManager +import com.pasich.encly.data.backup.ImportMode +import com.pasich.encly.data.backup.ImportSummary +import com.pasich.encly.data.backup.TagMatch +import com.pasich.encly.data.handoff.HandoffError +import com.pasich.encly.data.handoff.HandoffException +import com.pasich.encly.data.handoff.HandoffImport +import com.pasich.encly.data.handoff.HandoffPreview +import com.pasich.encly.data.handoff.HandoffStaging +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.File +import java.io.IOException +import javax.inject.Inject + +/** Where a My Notes hand-off is. */ +sealed interface HandoffStep { + /** Waiting for the vault: resolving its state, or for the user to unlock it. */ + data object Waiting : HandoffStep + + /** Copying, reading and mapping My Notes' data. */ + data object Reading : HandoffStep + + /** Nothing is written until the user confirms. */ + data class Preview(val preview: HandoffPreview) : HandoffStep + + data object Importing : HandoffStep + + /** [skipped]: already in the vault, plus records the hand-off could not use (blank tasks). */ + data class Done(val summary: ImportSummary, val skipped: Int) : HandoffStep + + /** [vaultUnavailable]: no vault to import into (not set up, or it cannot be opened here). */ + data class Failed(val error: HandoffError, val vaultUnavailable: Boolean = false) : HandoffStep +} + +/** + * The receiving side of the My Notes hand-off (see ImportFromMyNotesActivity, which checks + * the caller first). The vault must be unlocked through the normal lock screen before the + * URI is read at all; the data is then shown as counts, and imported only on [confirm]. + */ +@HiltViewModel +class ImportFromMyNotesViewModel @Inject constructor( + @param:ApplicationContext private val context: Context, + private val securityManager: SecurityManager, + private val sessionLockManager: SessionLockManager, + private val backupManager: BackupManager, + @param:IoDispatcher private val ioDispatcher: CoroutineDispatcher, +) : ViewModel() { + + private val staging = HandoffStaging(File(context.cacheDir, HandoffStaging.DIR_NAME)) + + private val _step = MutableStateFlow(HandoffStep.Waiting) + val step: StateFlow = _step.asStateFlow() + + /** True while the vault is closed: the lock screen is shown instead of the hand-off. */ + val locked: StateFlow = sessionLockManager.locked + + private var started = false + private var handoff: HandoffImport? = null + + /** Starts the hand-off of [uri] once; a recreated activity calls it again and is ignored. */ + fun start(uri: Uri) { + if (started) return + started = true + viewModelScope.launch { + if (!vaultOpen() && !requireUnlock()) { + _step.value = HandoffStep.Failed(HandoffError.FAILED, vaultUnavailable = true) + return@launch + } + sessionLockManager.locked.first { !it && securityManager.isDatabaseUnlocked() } + _step.value = HandoffStep.Reading + _step.value = read(uri) + } + } + + /** Imports the previewed data in one transaction; nothing is written on failure. */ + fun confirm() { + val pending = handoff ?: return + if (_step.value !is HandoffStep.Preview) return + _step.value = HandoffStep.Importing + viewModelScope.launch { + _step.value = try { + val summary = withContext(ioDispatcher) { + backupManager.import(pending.payload, ImportMode.MERGE, TagMatch.UID_OR_NAME) + } + handoff = null + HandoffStep.Done(summary, skipped = summary.skipped + pending.dropped) + } catch (_: BackupException) { + HandoffStep.Failed(HandoffError.FAILED) + } + } + } + + /** The lock screen found that nothing can open the vault on this device any more. */ + fun onVaultLost() { + handoff = null + _step.value = HandoffStep.Failed(HandoffError.FAILED, vaultUnavailable = true) + } + + override fun onCleared() { + handoff = null + staging.clear() + } + + private fun vaultOpen(): Boolean = securityManager.isDatabaseUnlocked() && !sessionLockManager.locked.value + + /** + * A committed vault that is closed shows the lock screen (as MainActivity does at startup). + * False when there is no vault to unlock: not set up yet, damaged, or from Encly 1.x. + */ + private suspend fun requireUnlock(): Boolean { + val status = withContext(ioDispatcher) { securityManager.resolveInitialStatus() } + if (status != InitialStatus.AUTH) return false + sessionLockManager.requireUnlock() + return true + } + + private suspend fun read(uri: Uri): HandoffStep = try { + val loaded = withContext(ioDispatcher) { staging.load { context.contentResolver.openInputStream(uri) } } + handoff = loaded + HandoffStep.Preview(loaded.preview) + } catch (e: HandoffException) { + HandoffStep.Failed(e.error) + } catch (_: BackupException) { + // The mapped data failed the backup validation: a malformed hand-off. + HandoffStep.Failed(HandoffError.INVALID_PAYLOAD) + } catch (_: IOException) { + HandoffStep.Failed(HandoffError.FAILED) + } catch (_: SecurityException) { + // No read grant on the URI. + HandoffStep.Failed(HandoffError.FAILED) + } +} diff --git a/app/src/main/res/values-de/strings_handoff.xml b/app/src/main/res/values-de/strings_handoff.xml new file mode 100644 index 0000000..fa8195c --- /dev/null +++ b/app/src/main/res/values-de/strings_handoff.xml @@ -0,0 +1,20 @@ + + + + Aus My Notes importieren + Notizen aus My Notes werden gelesen… + Hol deine Notizen in Encly + My Notes auf diesem Telefon übergibt %1$s, %2$s und %3$s. Sie kommen zu deinem verschlüsselten Tresor dazu: Hier wird nichts ersetzt, und bereits Importiertes wird übersprungen. + Anhänge, die in My Notes bleiben: %1$d. Encly importiert keine Bilder oder Dateien. + Angeheftete Notizen, die nicht angeheftet bleiben: %1$d. Encly kennt kein Anheften. + Notizen importieren + Wird importiert… + Importiert + Alles ist auch weiterhin in My Notes. Wenn du deine Notizen hier geprüft hast, kannst du sie dort löschen. + Nichts wurde importiert + My Notes hat die Notizen in einem neueren Format gesendet. Aktualisiere Encly und starte den Umzug in My Notes erneut. + Die Daten aus My Notes konnten nicht gelesen werden. + Die Daten aus My Notes sind zu groß, um sie auf einmal zu importieren. + Beim Import ist etwas schiefgelaufen. Versuche es erneut aus My Notes. + Encly kann deinen Tresor hier nicht öffnen. Öffne Encly, prüfe, dass es eingerichtet ist und sich entsperren lässt, und starte den Umzug in My Notes erneut. + diff --git a/app/src/main/res/values-es/strings_handoff.xml b/app/src/main/res/values-es/strings_handoff.xml new file mode 100644 index 0000000..fe02caf --- /dev/null +++ b/app/src/main/res/values-es/strings_handoff.xml @@ -0,0 +1,20 @@ + + + + Importar desde My Notes + Leyendo tus notas de My Notes… + Trae tus notas a Encly + My Notes en este teléfono te entrega %1$s, %2$s y %3$s. Se añaden a tu caja fuerte cifrada: aquí no se reemplaza nada y lo que ya se importó se omite. + Adjuntos que se quedan en My Notes: %1$d. Encly no importa imágenes ni archivos. + Notas fijadas que dejarán de estar fijadas: %1$d. Encly no tiene notas fijadas. + Importar notas + Importando… + Importado + Todo sigue también en My Notes. Cuando hayas revisado tus notas aquí, puedes borrarlas allí. + No se importó nada + My Notes envió las notas en un formato más reciente. Actualiza Encly y vuelve a iniciar el traslado en My Notes. + No se pudieron leer los datos de My Notes. + Los datos de My Notes son demasiado grandes para importarlos de una vez. + Algo salió mal al importar. Inténtalo de nuevo desde My Notes. + Encly no puede abrir tu caja fuerte aquí. Abre Encly, comprueba que está configurado y se desbloquea, y vuelve a iniciar el traslado en My Notes. + diff --git a/app/src/main/res/values-fr/strings_handoff.xml b/app/src/main/res/values-fr/strings_handoff.xml new file mode 100644 index 0000000..8c19367 --- /dev/null +++ b/app/src/main/res/values-fr/strings_handoff.xml @@ -0,0 +1,20 @@ + + + + Importer depuis My Notes + Lecture de vos notes depuis My Notes… + Importez vos notes dans Encly + My Notes sur ce téléphone transmet %1$s, %2$s et %3$s. Elles sont ajoutées à votre coffre-fort chiffré : rien n\'est remplacé ici, et ce qui a déjà été importé est ignoré. + Pièces jointes qui restent dans My Notes : %1$d. Encly n\'importe ni images ni fichiers. + Notes épinglées qui ne resteront pas épinglées : %1$d. Encly n\'a pas d\'épinglage. + Importer les notes + Importation… + Importé + Tout reste aussi dans My Notes. Une fois vos notes vérifiées ici, vous pourrez les effacer là-bas. + Rien n\'a été importé + My Notes a envoyé ses notes dans un format plus récent. Mettez Encly à jour, puis relancez le transfert dans My Notes. + Les données de My Notes n\'ont pas pu être lues. + Les données de My Notes sont trop volumineuses pour être importées en une fois. + Un problème est survenu pendant l\'importation. Réessayez depuis My Notes. + Encly ne peut pas ouvrir votre coffre-fort ici. Ouvrez Encly, vérifiez qu\'il est configuré et se déverrouille, puis relancez le transfert dans My Notes. + diff --git a/app/src/main/res/values-it/strings_handoff.xml b/app/src/main/res/values-it/strings_handoff.xml new file mode 100644 index 0000000..f790c35 --- /dev/null +++ b/app/src/main/res/values-it/strings_handoff.xml @@ -0,0 +1,20 @@ + + + + Importa da My Notes + Lettura delle note da My Notes… + Porta le tue note in Encly + My Notes su questo telefono consegna %1$s, %2$s e %3$s. Vengono aggiunte alla tua cassaforte cifrata: qui non viene sostituito nulla e ciò che è già stato importato viene saltato. + Allegati che restano in My Notes: %1$d. Encly non importa immagini né file. + Note fissate che non resteranno fissate: %1$d. Encly non ha le note fissate. + Importa le note + Importazione… + Importato + Tutto resta anche in My Notes. Dopo aver controllato le note qui, puoi cancellarle lì. + Non è stato importato nulla + My Notes ha inviato le note in un formato più recente. Aggiorna Encly, poi riavvia il trasferimento in My Notes. + Impossibile leggere i dati di My Notes. + I dati di My Notes sono troppo grandi per essere importati in una volta. + Qualcosa è andato storto durante l\'importazione. Riprova da My Notes. + Encly non riesce ad aprire qui la tua cassaforte. Apri Encly, verifica che sia configurata e che si sblocchi, poi riavvia il trasferimento in My Notes. + diff --git a/app/src/main/res/values-nl/strings_handoff.xml b/app/src/main/res/values-nl/strings_handoff.xml new file mode 100644 index 0000000..1bc4db2 --- /dev/null +++ b/app/src/main/res/values-nl/strings_handoff.xml @@ -0,0 +1,20 @@ + + + + Importeren uit My Notes + Je notities uit My Notes worden gelezen… + Haal je notities naar Encly + My Notes op deze telefoon draagt %1$s, %2$s en %3$s over. Ze worden toegevoegd aan je versleutelde kluis: hier wordt niets vervangen en wat al geïmporteerd is, wordt overgeslagen. + Bijlagen die in My Notes blijven: %1$d. Encly importeert geen afbeeldingen of bestanden. + Vastgezette notities die niet vastgezet blijven: %1$d. Encly kent geen vastzetten. + Notities importeren + Bezig met importeren… + Geïmporteerd + Alles staat ook nog in My Notes. Als je je notities hier hebt gecontroleerd, kun je ze daar wissen. + Er is niets geïmporteerd + My Notes heeft de notities in een nieuwer formaat gestuurd. Werk Encly bij en start de verhuizing opnieuw in My Notes. + De gegevens uit My Notes konden niet worden gelezen. + De gegevens uit My Notes zijn te groot om in één keer te importeren. + Er ging iets mis bij het importeren. Probeer het opnieuw vanuit My Notes. + Encly kan je kluis hier niet openen. Open Encly, controleer of hij is ingesteld en ontgrendelt, en start de verhuizing opnieuw in My Notes. + diff --git a/app/src/main/res/values-pl/strings_handoff.xml b/app/src/main/res/values-pl/strings_handoff.xml new file mode 100644 index 0000000..5a7dab6 --- /dev/null +++ b/app/src/main/res/values-pl/strings_handoff.xml @@ -0,0 +1,20 @@ + + + + Import z My Notes + Odczytywanie notatek z My Notes… + Przenieś notatki do Encly + My Notes na tym telefonie przekazuje: %1$s, %2$s i %3$s. Zostaną dodane do Twojego zaszyfrowanego sejfu: nic tu nie zostanie zastąpione, a to, co już zaimportowano, zostanie pominięte. + Załączniki, które zostają w My Notes: %1$d. Encly nie importuje obrazów ani plików. + Przypięte notatki, które nie pozostaną przypięte: %1$d. Encly nie ma przypinania. + Importuj notatki + Importowanie… + Zaimportowano + Wszystko nadal jest też w My Notes. Gdy sprawdzisz notatki tutaj, możesz je tam usunąć. + Nic nie zaimportowano + My Notes wysłał notatki w nowszym formacie. Zaktualizuj Encly i rozpocznij przenoszenie w My Notes jeszcze raz. + Nie udało się odczytać danych z My Notes. + Dane z My Notes są zbyt duże, aby zaimportować je za jednym razem. + Podczas importu coś poszło nie tak. Spróbuj ponownie z My Notes. + Encly nie może tutaj otworzyć Twojego sejfu. Otwórz Encly, upewnij się, że jest skonfigurowany i się odblokowuje, a potem rozpocznij przenoszenie w My Notes jeszcze raz. + diff --git a/app/src/main/res/values-pt/strings_handoff.xml b/app/src/main/res/values-pt/strings_handoff.xml new file mode 100644 index 0000000..00bd93d --- /dev/null +++ b/app/src/main/res/values-pt/strings_handoff.xml @@ -0,0 +1,20 @@ + + + + Importar do My Notes + Lendo suas notas do My Notes… + Traga suas notas para o Encly + O My Notes neste telefone está entregando %1$s, %2$s e %3$s. Elas são adicionadas ao seu cofre criptografado: nada aqui é substituído e o que já foi importado é ignorado. + Anexos que ficam no My Notes: %1$d. O Encly não importa imagens nem arquivos. + Notas fixadas que deixarão de estar fixadas: %1$d. O Encly não tem notas fixadas. + Importar notas + Importando… + Importado + Tudo continua também no My Notes. Depois de conferir suas notas aqui, você pode excluí-las lá. + Nada foi importado + O My Notes enviou as notas num formato mais recente. Atualize o Encly e inicie a transferência de novo no My Notes. + Não foi possível ler os dados do My Notes. + Os dados do My Notes são grandes demais para importar de uma vez. + Algo deu errado durante a importação. Tente de novo pelo My Notes. + O Encly não consegue abrir seu cofre aqui. Abra o Encly, confirme que ele está configurado e desbloqueia, e inicie a transferência de novo no My Notes. + diff --git a/app/src/main/res/values-uk/strings_handoff.xml b/app/src/main/res/values-uk/strings_handoff.xml new file mode 100644 index 0000000..84444cf --- /dev/null +++ b/app/src/main/res/values-uk/strings_handoff.xml @@ -0,0 +1,20 @@ + + + + Імпорт з My Notes + Читаємо ваші нотатки з My Notes… + Перенесіть нотатки в Encly + My Notes на цьому телефоні передає %1$s, %2$s і %3$s. Їх буде додано до вашого зашифрованого сховища: тут нічого не замінюється, а вже імпортоване пропускається. + Вкладень, що залишаться в My Notes: %1$d. Encly не імпортує зображення й файли. + Закріплених нотаток, які більше не будуть закріплені: %1$d. В Encly немає закріплення. + Імпортувати нотатки + Імпортуємо… + Імпортовано + Усе й далі є в My Notes. Коли перевірите нотатки тут, можете очистити їх там. + Нічого не імпортовано + My Notes передав нотатки в новішому форматі. Оновіть Encly і почніть перенесення в My Notes ще раз. + Не вдалося прочитати дані з My Notes. + Дані з My Notes завеликі, щоб Encly імпортував їх за один раз. + Під час імпорту щось пішло не так. Спробуйте ще раз з My Notes. + Encly не може тут відкрити ваше сховище. Відкрийте Encly, переконайтеся, що його налаштовано і воно розблоковується, і почніть перенесення в My Notes ще раз. + diff --git a/app/src/main/res/values/strings_handoff.xml b/app/src/main/res/values/strings_handoff.xml new file mode 100644 index 0000000..f60a5c7 --- /dev/null +++ b/app/src/main/res/values/strings_handoff.xml @@ -0,0 +1,20 @@ + + + + Import from My Notes + Reading your notes from My Notes… + Bring your notes into Encly + My Notes on this phone is handing over %1$s, %2$s and %3$s. They are added to your encrypted vault: nothing here is replaced, and anything imported before is skipped. + Attachments that stay in My Notes: %1$d. Encly does not import images or files. + Pinned notes that will not stay pinned: %1$d. Encly has no pinning. + Import notes + Importing… + Imported + Everything is still in My Notes too. Once you have checked your notes here, you can clear it there. + Nothing was imported + My Notes sent its notes in a newer format. Update Encly, then start the move again in My Notes. + The data from My Notes couldn\'t be read. + The data from My Notes is larger than Encly can import at once. + Something went wrong while importing. Try again from My Notes. + Encly can\'t open your vault here. Open Encly, make sure it is set up and unlocks, then start the move again in My Notes. + diff --git a/app/src/test/java/com/pasich/encly/ImportFromMyNotesActivityTest.kt b/app/src/test/java/com/pasich/encly/ImportFromMyNotesActivityTest.kt new file mode 100644 index 0000000..4f30b38 --- /dev/null +++ b/app/src/test/java/com/pasich/encly/ImportFromMyNotesActivityTest.kt @@ -0,0 +1,59 @@ +package com.pasich.encly + +import android.Manifest +import android.app.Activity +import android.app.Application +import android.content.Intent +import android.net.Uri +import androidx.test.core.app.ApplicationProvider +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.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config + +/** The exported hand-off activity refuses any caller that is not a verified My Notes. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class ImportFromMyNotesActivityTest { + private val application: Application get() = ApplicationProvider.getApplicationContext() + + private val handoffIntent = Intent(ImportFromMyNotesActivity.ACTION_IMPORT_FROM_MY_NOTES) + .setDataAndType(Uri.parse("content://com.pasich.mynotes.fileprovider/handoff/handoff.zip"), "application/zip") + .setClass(ApplicationProvider.getApplicationContext(), ImportFromMyNotesActivity::class.java) + + @Before + fun setUp() { + shadowOf(application).grantPermissions(Manifest.permission.HIDE_OVERLAY_WINDOWS) + } + + private fun refusalOf(callingPackage: String?): Pair { + val controller = Robolectric.buildActivity(ImportFromMyNotesActivity::class.java, handoffIntent) + val shadow = shadowOf(controller.get()) + shadow.setCallingPackage(callingPackage) + controller.create() + try { + assertTrue(controller.get().isFinishing) + return shadow.resultCode to shadow.resultIntent?.getStringExtra(ImportFromMyNotesActivity.EXTRA_REASON) + } finally { + controller.destroy() + } + } + + @Test + fun aCallerThatIsNotMyNotesIsRefused() { + assertEquals(Activity.RESULT_CANCELED to "untrusted_caller", refusalOf("com.example.other")) + } + + @Test + fun startedWithoutForResultIsRefused() { + assertEquals(Activity.RESULT_CANCELED to "untrusted_caller", refusalOf(null)) + } + + // My Notes' name with the wrong certificate: MyNotesCallerVerifierTest (Robolectric does not + // implement PackageManager.hasSigningCertificate). +} diff --git a/app/src/test/java/com/pasich/encly/data/handoff/HandoffFixtures.kt b/app/src/test/java/com/pasich/encly/data/handoff/HandoffFixtures.kt new file mode 100644 index 0000000..e81e886 --- /dev/null +++ b/app/src/test/java/com/pasich/encly/data/handoff/HandoffFixtures.kt @@ -0,0 +1,92 @@ +package com.pasich.encly.data.handoff + +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import java.io.ByteArrayOutputStream +import java.io.File +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream + +/** Builds hand-off JSON and ZIPs the way My Notes sends them (contract v1). */ +internal object HandoffFixtures { + + fun tag(id: String, name: String, position: Int = 0) = buildJsonObject { + put("id", id) + put("name", name) + put("position", position) + } + + fun category(id: String, name: String, position: Int = 0) = tag(id, name, position) + + @Suppress("LongParameterList") // Mirrors the contract's note record. + fun note( + id: String, + title: String = "Title $id", + value: String = "Text of $id", + valueJson: String? = null, + tag: String? = null, + isTrash: Boolean = false, + isPinned: Boolean = false, + attachments: Int = 0, + ) = buildJsonObject { + put("id", id) + put("title", title) + put("value", value) + put("valueJson", valueJson?.let(::JsonPrimitive) ?: JsonNull) + put("date", 1_700_000_000_000L) + put("tag", tag?.let(::JsonPrimitive) ?: JsonNull) + put("isTrash", isTrash) + put("isPinned", isPinned) + put("attachments", attachments) + } + + fun task(id: String, description: String, isDone: Boolean = false, categoryId: String? = null, position: Int = 0) = + buildJsonObject { + put("id", id) + put("description", description) + put("isDone", isDone) + put("createdAt", 1_600_000_000_000L) + put("categoryId", categoryId?.let(::JsonPrimitive) ?: JsonNull) + put("position", position) + } + + fun handoff( + tags: List = emptyList(), + notes: List = emptyList(), + categories: List = emptyList(), + tasks: List = emptyList(), + schema: Int = 1, + format: String = MyNotesHandoff.FORMAT, + ) = buildJsonObject { + put("format", format) + put("schema", schema) + put("exportedAt", 1_750_000_000_000L) + put("tags", JsonArray(tags)) + put("notes", JsonArray(notes)) + put("taskCategories", JsonArray(categories)) + put("tasks", JsonArray(tasks)) + } + + /** A ZIP with the given entries (name -> UTF-8 content), written to [dir]. */ + fun zip(dir: File, vararg entries: Pair): File { + val bytes = ByteArrayOutputStream() + ZipOutputStream(bytes).use { zip -> + entries.forEach { (name, content) -> + zip.putNextEntry(ZipEntry(name)) + zip.write(content) + zip.closeEntry() + } + } + return File.createTempFile("handoff", ".zip", dir).apply { writeBytes(bytes.toByteArray()) } + } + + fun handoffZip(dir: File, json: JsonElement): File = + zip(dir, MyNotesHandoffReader.ENTRY_NAME to json.toString().toByteArray(Charsets.UTF_8)) + + fun read(dir: File, json: JsonElement): MyNotesHandoff = MyNotesHandoffReader.read(handoffZip(dir, json)) +} diff --git a/app/src/test/java/com/pasich/encly/data/handoff/HandoffStagingTest.kt b/app/src/test/java/com/pasich/encly/data/handoff/HandoffStagingTest.kt new file mode 100644 index 0000000..8445cf1 --- /dev/null +++ b/app/src/test/java/com/pasich/encly/data/handoff/HandoffStagingTest.kt @@ -0,0 +1,63 @@ +package com.pasich.encly.data.handoff + +import com.pasich.encly.data.handoff.HandoffFixtures.handoff +import com.pasich.encly.data.handoff.HandoffFixtures.handoffZip +import com.pasich.encly.data.handoff.HandoffFixtures.note +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File +import java.io.IOException +import java.io.InputStream + +class HandoffStagingTest { + + @get:Rule + val temp = TemporaryFolder() + + private val stagingDir: File get() = File(temp.root, HandoffStaging.DIR_NAME) + + @Test + fun loadsAndLeavesNothingBehind() { + val zip = handoffZip(temp.newFolder("source"), handoff(notes = listOf(note("n1")))) + val loaded = HandoffStaging(stagingDir).load { zip.inputStream() } + assertEquals(1, loaded.preview.notes) + assertFalse(stagingDir.exists()) + } + + @Test + fun aFailedReadLeavesNothingBehind() { + assertError(HandoffError.INVALID_PAYLOAD) { + HandoffStaging(stagingDir).load { "not a zip".byteInputStream() } + } + assertFalse(stagingDir.exists()) + assertError(HandoffError.TOO_LARGE) { + HandoffStaging(stagingDir, HandoffLimits(maxZipBytes = 8)).load { ByteArray(64).inputStream() } + } + assertFalse(stagingDir.exists()) + } + + @Test + fun anUnreadableUriLeavesNothingBehind() { + val failing = object : InputStream() { + override fun read(): Int = throw IOException("revoked") + } + try { + HandoffStaging(stagingDir).load { failing } + } catch (_: IOException) { + // Expected: the ViewModel reports it as "failed". + } + assertFalse(stagingDir.exists()) + assertError(HandoffError.INVALID_PAYLOAD) { HandoffStaging(stagingDir).load { null } } + } + + @Test + fun leftoversOfAKilledRunAreCleared() { + stagingDir.mkdirs() + File(stagingDir, "handoff.zip").writeText("plaintext from a run that died") + HandoffStaging(stagingDir).clear() + assertFalse(stagingDir.exists()) + } +} diff --git a/app/src/test/java/com/pasich/encly/data/handoff/InlineHtmlTest.kt b/app/src/test/java/com/pasich/encly/data/handoff/InlineHtmlTest.kt new file mode 100644 index 0000000..3edcff6 --- /dev/null +++ b/app/src/test/java/com/pasich/encly/data/handoff/InlineHtmlTest.kt @@ -0,0 +1,49 @@ +package com.pasich.encly.data.handoff + +import org.junit.Assert.assertEquals +import org.junit.Test + +class InlineHtmlTest { + + @Test + fun formattingTagsAreRemoved() { + assertEquals( + "bold italic marked code link", + InlineHtml.toPlainText( + "bold italic marked " + + "code
link", + ), + ) + } + + @Test + fun lineBreaksBecomeNewlines() { + assertEquals("a\nb\nc\nd", InlineHtml.toPlainText("a
b
c
d")) + } + + @Test + fun entitiesAreDecodedOnce() { + assertEquals( + " & \"q\" 'a' x y", + InlineHtml.toPlainText("<b> & "q" 'a' x y"), + ) + assertEquals("<", InlineHtml.toPlainText("&lt;")) + assertEquals("€ 😀", InlineHtml.toPlainText("€ 😀")) + } + + @Test + fun unknownOrInvalidEntitiesStayAsTyped() { + assertEquals("&bogus; � � �", InlineHtml.toPlainText("&bogus; � � �")) + } + + @Test + fun textThatOnlyLooksLikeMarkupIsKept() { + assertEquals("a < b > c, 3<4", InlineHtml.toPlainText("a < b > c, 3<4")) + } + + @Test + fun invisibleEditorCharactersAreDropped() { + val input = "${Char(0x200B)}a${Char(0x00A0)}b${Char(0xFEFF)}" + assertEquals("a b", InlineHtml.toPlainText(input)) + } +} diff --git a/app/src/test/java/com/pasich/encly/data/handoff/MyNotesBlockMapperTest.kt b/app/src/test/java/com/pasich/encly/data/handoff/MyNotesBlockMapperTest.kt new file mode 100644 index 0000000..97d2b63 --- /dev/null +++ b/app/src/test/java/com/pasich/encly/data/handoff/MyNotesBlockMapperTest.kt @@ -0,0 +1,183 @@ +package com.pasich.encly.data.handoff + +import com.pasich.encly.core.serialization.BlockConverter +import com.pasich.encly.domain.model.ItemListBlock +import com.pasich.encly.dynamicBlocks.Block +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class MyNotesBlockMapperTest { + + private fun note(value: String = "", valueJson: String? = null) = HandoffNote( + id = "n", + title = "t", + value = value, + valueJson = valueJson, + date = 0, + isTrash = false, + isPinned = false, + attachments = 0, + ) + + /** Maps [blocks] (Editor.js block objects) and reads them back as stored. */ + private fun mapEditor(vararg blocks: String): List = + BlockConverter.jsonToBlocks(MyNotesBlockMapper.toBlocksJson(note(valueJson = "[${blocks.joinToString()}]"))) + + private fun texts(blocks: List) = blocks.map { + when (it) { + is Block.TextBlock -> "TEXT:${it.text.value}" + is Block.HBlock -> "${it.blockType}:${it.text.value}" + is Block.ListBlock -> "${it.blockType}:" + it.items.value.joinToString("|") { item -> item.label() } + is Block.SeparatorBlock -> "SEPARATOR" + else -> it.toString() + } + } + + private fun ItemListBlock.label() = if (isCheck) "[x]$value" else value + + @Test + fun plainNoteBecomesOneTextBlockPerParagraph() { + val blocks = BlockConverter.jsonToBlocks( + MyNotesBlockMapper.toBlocksJson(note(value = "First line\nsame paragraph\n\n\nSecond\r\n \r\nThird ")), + ) + assertEquals(listOf("TEXT:First line\nsame paragraph", "TEXT:Second", "TEXT:Third"), texts(blocks)) + } + + @Test + fun blankPlainNoteHasNoBlocks() { + assertEquals("[]", MyNotesBlockMapper.toBlocksJson(note(value = " \n\n "))) + } + + @Test + fun paragraphIsText() { + assertEquals(listOf("TEXT:Hello"), texts(mapEditor("""{"type":"paragraph","data":{"text":"Hello"}}"""))) + } + + @Test + fun headersKeepTheirLevelUpToFour() { + val blocks = mapEditor( + """{"type":"header","data":{"text":"One","level":1}}""", + """{"type":"Headers","data":{"text":"Two","level":2}}""", + """{"type":"Headers","data":{"text":"Three","level":3}}""", + """{"type":"header","data":{"text":"Four","level":4}}""", + """{"type":"header","data":{"text":"Six","level":6}}""", + """{"type":"Headers","data":{"text":"Default"}}""", + ) + assertEquals( + listOf("H1:One", "H2:Two", "H3:Three", "H4:Four", "H4:Six", "H2:Default"), + texts(blocks), + ) + } + + @Test + fun listsMapByStyleAndFlattenNestedItems() { + val blocks = mapEditor( + """{"type":"list","data":{"style":"unordered","items":["a","b"]}}""", + """{"type":"list","data":{"style":"ordered","items":[ + {"content":"1","meta":{},"items":[{"content":"1.1","meta":{},"items":[]}]}, + {"content":"2","meta":{},"items":[]}]}}""", + """{"type":"list","data":{"style":"checklist","items":[ + {"content":"done","meta":{"checked":true},"items":[]}, + {"content":"open","meta":{"checked":false},"items":[]}]}}""", + ) + assertEquals( + listOf("LIST_BULLET:a|b", "LIST_NUMBER:1|1.1|2", "LIST_CHECK:[x]done|open"), + texts(blocks), + ) + } + + @Test + fun checklistToolIsAChecklist() { + val blocks = mapEditor( + """{"type":"checklist","data":{"items":[""" + + """{"text":"milk","checked":true},{"text":"eggs","checked":false}]}}""", + ) + assertEquals(listOf("LIST_CHECK:[x]milk|eggs"), texts(blocks)) + } + + @Test + fun delimiterIsASeparatorAndSpacerIsDropped() { + val blocks = mapEditor( + """{"type":"delimiter","data":{}}""", + """{"type":"spacer","data":{"height":20}}""", + ) + assertEquals(listOf("SEPARATOR"), texts(blocks)) + } + + @Test + fun imagesAndAttachmentsAreDropped() { + val blocks = mapEditor( + """{"type":"image","data":{"file":{"url":"file:///a.png"},"caption":"cap"}}""", + """{"type":"attaches","data":{"file":{"url":"file:///b.pdf","name":"b.pdf"}}}""", + """{"type":"paragraph","data":{"text":"kept"}}""", + ) + assertEquals(listOf("TEXT:kept"), texts(blocks)) + } + + @Test + fun inlineHtmlBecomesPlainText() { + val blocks = mapEditor( + """{"type":"paragraph","data":{"text":"bold it mark""" + + """
x < y & link""" + + """ €😀"}}""", + """{"type":"list","data":{"style":"unordered","items":["item"]}}""", + ) + assertEquals(listOf("TEXT:bold it mark\nx < y & link €😀", "LIST_BULLET:item"), texts(blocks)) + val bold = """[{"type":"paragraph","data":{"text":"b"}}]""" + val stored = MyNotesBlockMapper.toBlocksJson(note(valueJson = bold)) + assertFalse(stored.contains("")) + } + + @Test + fun emptyBlocksAreDropped() { + val blocks = mapEditor( + """{"type":"paragraph","data":{"text":"
"}}""", + """{"type":"header","data":{"text":" ","level":2}}""", + """{"type":"list","data":{"style":"unordered","items":["", "
"]}}""", + ) + assertTrue(blocks.isEmpty()) + } + + @Test + fun unknownToolsKeepTheirText() { + val blocks = mapEditor( + """{"type":"quote","data":{"text":"said","caption":""}}""", + """{"type":"table","data":{"content":[["a"]]}}""", + ) + assertEquals(listOf("TEXT:said"), texts(blocks)) + } + + @Test + fun editorDocumentObjectIsReadToo() { + val json = """{"time":1,"blocks":[{"type":"paragraph","data":{"text":"inside"}}],"version":"2.30"}""" + val blocks = BlockConverter.jsonToBlocks(MyNotesBlockMapper.toBlocksJson(note(valueJson = json))) + assertEquals(listOf("TEXT:inside"), texts(blocks)) + } + + @Test + fun unreadableEditorJsonFallsBackToThePlainText() { + val blocks = BlockConverter.jsonToBlocks( + MyNotesBlockMapper.toBlocksJson(note(value = "fallback", valueJson = "{not json")), + ) + assertEquals(listOf("TEXT:fallback"), texts(blocks)) + } + + @Test + fun nestedListsAreFlattenedToALimitedDepth() { + var item = """{"content":"leaf","items":[]}""" + repeat(50) { item = """{"content":"n","items":[$item]}""" } + val blocks = mapEditor("""{"type":"list","data":{"style":"unordered","items":[$item]}}""") + assertEquals(33, (blocks.single() as Block.ListBlock).items.value.size) + } + + @Test + fun hostileNestingFallsBackToThePlainTextInsteadOfOverflowing() { + val deep = "[".repeat(100_000) + "]".repeat(100_000) + val blocks = BlockConverter.jsonToBlocks( + MyNotesBlockMapper.toBlocksJson(note(value = "kept", valueJson = deep)), + ) + assertEquals(listOf("TEXT:kept"), texts(blocks)) + } +} diff --git a/app/src/test/java/com/pasich/encly/data/handoff/MyNotesCallerVerifierTest.kt b/app/src/test/java/com/pasich/encly/data/handoff/MyNotesCallerVerifierTest.kt new file mode 100644 index 0000000..32f888f --- /dev/null +++ b/app/src/test/java/com/pasich/encly/data/handoff/MyNotesCallerVerifierTest.kt @@ -0,0 +1,91 @@ +package com.pasich.encly.data.handoff + +import com.pasich.encly.data.handoff.MyNotesCallerVerifier.Companion.MY_NOTES_PACKAGE +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.security.MessageDigest + +class MyNotesCallerVerifierTest { + + private val myNotesCert = "my notes release certificate".toByteArray() + private val otherCert = "someone else's certificate".toByteArray() + private val pinned = setOf(sha256Hex(myNotesCert)) + + /** A package manager that knows one installed package and its signers. */ + private class FakeSignatures( + override val sdkInt: Int, + private val installed: String? = MY_NOTES_PACKAGE, + private val signers: List = emptyList(), + ) : PackageSignatures { + val queried = mutableListOf() + + override fun hasSigningCertificateSha256(packageName: String, sha256: ByteArray): Boolean { + queried += packageName + return packageName == installed && signers.any { MessageDigest.isEqual(digest(it), sha256) } + } + + override fun legacySignatures(packageName: String): List? { + queried += packageName + return if (packageName == installed) signers else null + } + } + + private fun verifier(signatures: PackageSignatures) = MyNotesCallerVerifier(signatures, pinned) + + @Test + fun myNotesWithThePinnedCertificateIsTrusted() { + assertTrue(verifier(FakeSignatures(API_28, signers = listOf(myNotesCert))).isTrusted(MY_NOTES_PACKAGE)) + assertTrue(verifier(FakeSignatures(API_34, signers = listOf(myNotesCert))).isTrusted(MY_NOTES_PACKAGE)) + } + + @Test + fun anotherOrMissingCallerIsRefusedWithoutAPackageLookup() { + val signatures = FakeSignatures(API_34, installed = "com.evil.notes", signers = listOf(myNotesCert)) + assertFalse(verifier(signatures).isTrusted("com.evil.notes")) + assertFalse(verifier(signatures).isTrusted(null)) + assertFalse(verifier(signatures).isTrusted("com.pasich.mynotes.debug")) + assertTrue(signatures.queried.isEmpty()) + } + + @Test + fun myNotesSignedWithAnotherCertificateIsRefused() { + assertFalse(verifier(FakeSignatures(API_34, signers = listOf(otherCert))).isTrusted(MY_NOTES_PACKAGE)) + assertFalse(verifier(FakeSignatures(API_26, signers = listOf(otherCert))).isTrusted(MY_NOTES_PACKAGE)) + } + + @Test + fun beforeApi28OneSignerWithThePinnedCertificateIsTrusted() { + assertTrue(verifier(FakeSignatures(API_26, signers = listOf(myNotesCert))).isTrusted(MY_NOTES_PACKAGE)) + assertTrue(verifier(FakeSignatures(API_27, signers = listOf(myNotesCert))).isTrusted(MY_NOTES_PACKAGE)) + } + + @Test + fun beforeApi28SeveralSignersOrNoneAreRefused() { + val twoSigners = FakeSignatures(API_27, signers = listOf(myNotesCert, otherCert)) + assertFalse(verifier(twoSigners).isTrusted(MY_NOTES_PACKAGE)) + assertFalse(verifier(FakeSignatures(API_27, signers = emptyList())).isTrusted(MY_NOTES_PACKAGE)) + assertFalse(verifier(FakeSignatures(API_27, installed = null)).isTrusted(MY_NOTES_PACKAGE)) + } + + @Test + fun theShippedPinIsTheGitHubReleaseCertificate() { + assertEquals( + setOf("03f2b8c7c96778b7efb80bb08af99b273a6c0c24e5864d0a211c3a8e3d02483f"), + MyNotesCallerVerifier.TRUSTED_MY_NOTES_CERT_SHA256, + ) + MyNotesCallerVerifier.TRUSTED_MY_NOTES_CERT_SHA256.forEach { assertTrue(it.matches(Regex("[0-9a-f]{64}"))) } + } + + private companion object { + const val API_26 = 26 + const val API_27 = 27 + const val API_28 = 28 + const val API_34 = 34 + + fun digest(bytes: ByteArray): ByteArray = MessageDigest.getInstance("SHA-256").digest(bytes) + + fun sha256Hex(bytes: ByteArray): String = digest(bytes).joinToString("") { "%02x".format(it) } + } +} diff --git a/app/src/test/java/com/pasich/encly/data/handoff/MyNotesHandoffImportTest.kt b/app/src/test/java/com/pasich/encly/data/handoff/MyNotesHandoffImportTest.kt new file mode 100644 index 0000000..99369bd --- /dev/null +++ b/app/src/test/java/com/pasich/encly/data/handoff/MyNotesHandoffImportTest.kt @@ -0,0 +1,145 @@ +package com.pasich.encly.data.handoff + +import com.pasich.encly.core.backup.BackupPayloadCodec +import com.pasich.encly.core.serialization.BlockConverter +import com.pasich.encly.data.backup.BackupImporter +import com.pasich.encly.data.backup.ImportMode +import com.pasich.encly.data.backup.TagMatch +import com.pasich.encly.data.handoff.HandoffFixtures.category +import com.pasich.encly.data.handoff.HandoffFixtures.handoff +import com.pasich.encly.data.handoff.HandoffFixtures.note +import com.pasich.encly.data.handoff.HandoffFixtures.read +import com.pasich.encly.data.handoff.HandoffFixtures.tag +import com.pasich.encly.data.handoff.HandoffFixtures.task +import com.pasich.encly.data.model.Tag +import com.pasich.encly.dynamicBlocks.Block +import com.pasich.encly.testutil.InMemoryVaultDataStore +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.JsonObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder + +/** Hand-off -> mapper -> the backup MERGE import, against an in-memory vault. */ +class MyNotesHandoffImportTest { + + @get:Rule + val temp = TemporaryFolder() + + private fun mapped(json: JsonObject): HandoffImport = MyNotesHandoffMapper.map(read(temp.root, json)) + + private suspend fun import(json: JsonObject, store: InMemoryVaultDataStore) = + BackupImporter.import(mapped(json).payload, ImportMode.MERGE, store, TagMatch.UID_OR_NAME) + + private val sample = handoff( + tags = listOf(tag("mynotes:tag:1", "Work", position = 0), tag("mynotes:tag:2", "Home", position = 1)), + notes = listOf( + note("note-a", title = " Plan ", tag = "Work", isPinned = true, attachments = 2), + note("note-b", tag = "Unknown", isTrash = true, attachments = 1), + ), + categories = listOf(category("mynotes:category:1", "Errands")), + tasks = listOf( + task("mynotes:task:1", "Buy milk\nand eggs\n", isDone = true, categoryId = "mynotes:category:1"), + task("mynotes:task:2", "Call"), + task("mynotes:task:3", " "), + ), + ) + + @Test + fun mapsRecordsAndCountsWhatDoesNotComeOver() { + val result = mapped(sample) + assertEquals(HandoffPreview(notes = 2, tasks = 2, tags = 3, attachments = 3, pinned = 1), result.preview) + assertEquals(1, result.dropped) + + val payload = result.payload + val plan = payload.notes.first { it.uid == "note-a" } + assertEquals("Plan", plan.title) + assertEquals("mynotes:tag:1", plan.tagUid) + assertEquals(1_700_000_000_000L, plan.dateCreate) + val trashed = payload.notes.first { it.uid == "note-b" } + assertTrue(trashed.isTrash) + assertNull(trashed.tagUid) + assertTrue(BlockConverter.jsonToBlocks(trashed.value).single() is Block.TextBlock) + + val milk = payload.tasks.first { it.uid == "mynotes:task:1" } + assertEquals("Buy milk", milk.title) + assertEquals("and eggs", milk.description) + assertTrue(milk.isCompleted) + assertEquals(milk.createdDate, milk.completedDate) + assertEquals("mynotes:category:1", milk.categoryTagUid) + val call = payload.tasks.first { it.uid == "mynotes:task:2" } + assertNull(call.description) + assertNull(call.completedDate) + } + + @Test + fun importsOnceAndARepeatedHandoffAddsNothing() = runTest { + val store = InMemoryVaultDataStore() + val first = import(sample, store) + assertEquals(2, first.notesAdded) + assertEquals(2, first.tasksAdded) + assertEquals(3, first.tagsAdded) + assertEquals(0, first.skipped) + + val second = import(sample, store) + assertEquals(0, second.notesAdded + second.tasksAdded + second.tagsAdded) + assertEquals(7, second.skipped) + assertEquals(2, store.notes.size) + assertEquals(2, store.tasks.size) + assertEquals(3, store.tags.size) + } + + @Test + fun tagsMatchTheVaultByNameAndCategoriesShareTagsByName() = runTest { + val store = InMemoryVaultDataStore() + val work = store.insertTag(Tag(nameTag = "work", position = 0, uid = "encly-work")) + val json = handoff( + tags = listOf(tag("t1", " Work "), tag("t2", "Ideas")), + notes = listOf(note("n1", tag = "Work"), note("n2", tag = "ideas")), + categories = listOf(category("c1", "IDEAS"), category("c2", "Shopping"), category("c3", " ")), + tasks = listOf(task("k1", "a", categoryId = "c1"), task("k2", "b", categoryId = "c3")), + ) + val summary = import(json, store) + + assertEquals(listOf("work", "Ideas", "Shopping"), store.tags.map { it.nameTag }) + assertEquals(2, summary.tagsAdded) + val ideas = store.tags.first { it.nameTag == "Ideas" }.id + assertEquals(work, store.notes.first { it.uid == "n1" }.tagId) + assertEquals(ideas, store.notes.first { it.uid == "n2" }.tagId) + assertEquals(ideas, store.tasks.first { it.uid == "k1" }.categoryId) + assertNull(store.tasks.first { it.uid == "k2" }.categoryId) + + // Again: nothing new, and still no second "Work" or "Ideas". + import(json, store) + assertEquals(3, store.tags.size) + } + + @Test + fun tagMatchByUidOnlyKeepsTheOldBehaviour() = runTest { + val store = InMemoryVaultDataStore() + store.insertTag(Tag(nameTag = "Work", uid = "encly-work")) + BackupImporter.import(mapped(handoff(tags = listOf(tag("t1", "Work")))).payload, ImportMode.MERGE, store) + assertEquals(2, store.tags.count { it.nameTag == "Work" }) + } + + @Test + fun longIdsBecomeStableDigests() { + val longId = "x".repeat(BackupPayloadCodec.MAX_UID_LENGTH + 1) + val json = handoff(notes = listOf(note(longId))) + val first = mapped(json).payload.notes.single().uid + assertEquals(BackupPayloadCodec.MAX_UID_LENGTH, first.length) + assertEquals(first, mapped(json).payload.notes.single().uid) + assertEquals("short-id", MyNotesHandoffMapper.uidFor("note", "short-id")) + } + + @Test + fun aTagAndACategoryWithTheSameIdGetDifferentUids() { + val payload = mapped( + handoff(tags = listOf(tag("same", "A")), categories = listOf(category("same", "B"))), + ).payload + assertEquals(2, payload.tags.map { it.uid }.toSet().size) + } +} diff --git a/app/src/test/java/com/pasich/encly/data/handoff/MyNotesHandoffReaderTest.kt b/app/src/test/java/com/pasich/encly/data/handoff/MyNotesHandoffReaderTest.kt new file mode 100644 index 0000000..f231cf9 --- /dev/null +++ b/app/src/test/java/com/pasich/encly/data/handoff/MyNotesHandoffReaderTest.kt @@ -0,0 +1,184 @@ +package com.pasich.encly.data.handoff + +import com.pasich.encly.data.handoff.HandoffFixtures.handoff +import com.pasich.encly.data.handoff.HandoffFixtures.handoffZip +import com.pasich.encly.data.handoff.HandoffFixtures.note +import com.pasich.encly.data.handoff.HandoffFixtures.read +import com.pasich.encly.data.handoff.HandoffFixtures.tag +import com.pasich.encly.data.handoff.HandoffFixtures.task +import com.pasich.encly.data.handoff.HandoffFixtures.zip +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.fail +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.File +import java.io.InputStream + +class MyNotesHandoffReaderTest { + + @get:Rule + val temp = TemporaryFolder() + + private val dir: File get() = temp.root + + @Test + fun readsAValidHandoff() { + val parsed = read( + dir, + handoff( + tags = listOf(tag("t1", "Work")), + notes = listOf(note("n1", tag = "Work", isPinned = true, attachments = 2)), + tasks = listOf(task("k1", "Call")), + ), + ) + assertEquals(1, parsed.schema) + assertEquals("Work", parsed.tags.single().name) + assertEquals("Work", parsed.notes.single().tag) + assertEquals(2, parsed.notes.single().attachments) + assertEquals("Call", parsed.tasks.single().description) + } + + @Test + fun unknownKeysAreIgnoredAndOmittedNullsAreNull() { + val noteWithoutNulls = JsonObject(note("n1") - "valueJson" - "tag" + ("color" to JsonPrimitive("red"))) + val json = JsonObject(handoff(notes = listOf(noteWithoutNulls)) + ("future" to JsonPrimitive(true))) + val parsed = read(dir, json) + assertNull(parsed.notes.single().valueJson) + assertNull(parsed.notes.single().tag) + } + + @Test + fun aNewerSchemaAsksForAnUpdate() { + assertError(HandoffError.UNSUPPORTED_SCHEMA) { read(dir, handoff(schema = 2)) } + } + + @Test + fun aNewerSchemaIsReportedEvenIfItsShapeChanged() { + val v2 = buildJsonObject { + put("format", MyNotesHandoff.FORMAT) + put("schema", 2) + put("notes", "a different shape") + } + assertError(HandoffError.UNSUPPORTED_SCHEMA) { read(dir, v2) } + } + + @Test + fun wrongFormatOrSchemaIsInvalid() { + assertError(HandoffError.INVALID_PAYLOAD) { read(dir, handoff(format = "encly-backup")) } + assertError(HandoffError.INVALID_PAYLOAD) { read(dir, handoff(schema = 0)) } + assertError(HandoffError.INVALID_PAYLOAD) { read(dir, JsonObject(handoff() - "schema")) } + } + + @Test + fun malformedJsonIsInvalid() { + val broken = zip(dir, MyNotesHandoffReader.ENTRY_NAME to "{\"format\": \"mynotes-handoff\", ".toByteArray()) + assertError(HandoffError.INVALID_PAYLOAD) { MyNotesHandoffReader.read(broken) } + } + + @Test + fun aMissingRequiredFieldOrWrongTypeIsInvalid() { + val untitled = JsonObject(note("n1") - "title") + assertError(HandoffError.INVALID_PAYLOAD) { read(dir, handoff(notes = listOf(untitled))) } + val stringFlag = JsonObject(note("n1") + ("isTrash" to JsonPrimitive("yes"))) + assertError(HandoffError.INVALID_PAYLOAD) { read(dir, handoff(notes = listOf(stringFlag))) } + } + + @Test + fun duplicateOrBlankIdsAndUnknownCategoriesAreInvalid() { + assertError(HandoffError.INVALID_PAYLOAD) { read(dir, handoff(notes = listOf(note("n1"), note("n1")))) } + assertError(HandoffError.INVALID_PAYLOAD) { read(dir, handoff(notes = listOf(note(" ")))) } + assertError(HandoffError.INVALID_PAYLOAD) { + read(dir, handoff(tasks = listOf(task("k1", "Call", categoryId = "missing")))) + } + assertError(HandoffError.INVALID_PAYLOAD) { read(dir, handoff(notes = listOf(note("n1", attachments = -1)))) } + } + + @Test + fun notAZipIsInvalid() { + val file = File(dir, "plain.zip").apply { writeText("not a zip at all") } + assertError(HandoffError.INVALID_PAYLOAD) { MyNotesHandoffReader.read(file) } + } + + @Test + fun theZipMustHoldExactlyHandoffJson() { + val json = handoff().toString().toByteArray() + assertError(HandoffError.INVALID_PAYLOAD) { MyNotesHandoffReader.read(zip(dir, "other.json" to json)) } + assertError(HandoffError.INVALID_PAYLOAD) { + MyNotesHandoffReader.read(zip(dir, MyNotesHandoffReader.ENTRY_NAME to json, "extra.bin" to byteArrayOf(1))) + } + assertError(HandoffError.INVALID_PAYLOAD) { MyNotesHandoffReader.read(zip(dir)) } + assertError(HandoffError.INVALID_PAYLOAD) { + MyNotesHandoffReader.read(zip(dir, "../${MyNotesHandoffReader.ENTRY_NAME}" to json)) + } + } + + @Test + fun deeplyNestedUnknownKeysDoNotOverflow() { + val deep = "[".repeat(200_000) + "]".repeat(200_000) + val json = handoff().toString().replaceFirst("{", "{\"future\":$deep,") + val file = zip(dir, MyNotesHandoffReader.ENTRY_NAME to json.toByteArray()) + assertEquals(1, MyNotesHandoffReader.read(file).schema) + } + + @Test + fun aZipBombStopsAtTheUncompressedLimit() { + // A few KB compressed, 8 MB of spaces uncompressed: valid JSON padding, so only the limit stops it. + val padded = handoff().toString().replaceFirst("{", "{" + " ".repeat(8 * 1024 * 1024)) + val bomb = zip(dir, MyNotesHandoffReader.ENTRY_NAME to padded.toByteArray()) + val limits = HandoffLimits(maxJsonBytes = 1024L * 1024) + assertEquals(true, bomb.length() < limits.maxJsonBytes) + assertError(HandoffError.TOO_LARGE) { MyNotesHandoffReader.read(bomb, limits) } + } + + @Test + fun anOversizedZipIsRefusedBeforeReading() { + val file = handoffZip(dir, handoff(notes = listOf(note("n1")))) + assertError(HandoffError.TOO_LARGE) { MyNotesHandoffReader.read(file, HandoffLimits(maxZipBytes = 10)) } + } + + @Test + fun tooManyRecordsOrTooLongTextIsTooLarge() { + val three = handoff(notes = listOf(note("a"), note("b"), note("c"))) + assertError(HandoffError.TOO_LARGE) { + MyNotesHandoffReader.read(handoffZip(dir, three), HandoffLimits(maxRecords = 2)) + } + val long = handoff(notes = listOf(note("a", value = "x".repeat(101)))) + assertError(HandoffError.TOO_LARGE) { + MyNotesHandoffReader.read(handoffZip(dir, long), HandoffLimits(maxTextChars = 100)) + } + val longName = handoff(tags = listOf(tag("t", "n".repeat(11)))) + assertError(HandoffError.TOO_LARGE) { + MyNotesHandoffReader.read(handoffZip(dir, longName), HandoffLimits(maxNameChars = 10)) + } + } + + @Test + fun copyStopsAtTheZipLimit() { + val endless = object : InputStream() { + override fun read(): Int = 0 + } + assertError(HandoffError.TOO_LARGE) { + MyNotesHandoffReader.copyLimited(endless, ByteArrayOutputStream(), HandoffLimits(maxZipBytes = 4096)) + } + val out = ByteArrayOutputStream() + MyNotesHandoffReader.copyLimited(ByteArrayInputStream(ByteArray(4096)), out, HandoffLimits(maxZipBytes = 4096)) + assertEquals(4096, out.size()) + } +} + +internal fun assertError(expected: HandoffError, block: () -> Unit) { + try { + block() + fail("expected $expected") + } catch (e: HandoffException) { + assertEquals(expected, e.error) + } +} From 118cb906f8055a0503d2149539755d507edb7650 Mon Sep 17 00:00:00 2001 From: pasichdev Date: Mon, 5 Oct 2026 00:33:20 +0300 Subject: [PATCH 04/19] fix(lock): keep the wipe-PIN note guard behind the shared lock exits The My Notes import moved the lock screen's navigation into LockExits, and the wipe PIN made leaving the lock screen skip reopening the note that was open before a wipe-PIN unlock. The NavHost lock screen now reads canReopenNote from the same LockViewModel instance and passes it on, so both changes hold together. Refs: #52, #46 --- .../pasich/encly/presentation/screen/LockScreen.kt | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/pasich/encly/presentation/screen/LockScreen.kt b/app/src/main/java/com/pasich/encly/presentation/screen/LockScreen.kt index b9d5dae..7fa9f55 100644 --- a/app/src/main/java/com/pasich/encly/presentation/screen/LockScreen.kt +++ b/app/src/main/java/com/pasich/encly/presentation/screen/LockScreen.kt @@ -45,16 +45,14 @@ import com.pasich.encly.ui.theme.EnclyTheme /** The app's lock screen: unlocking opens Home (or the note that was open), see [leaveLockScreen]. */ @Composable -fun LockScreen( - navController: NavHostController, - modifier: Modifier = Modifier, - viewModel: LockViewModel = hiltViewModel(), -) { +fun LockScreen(navController: NavHostController, modifier: Modifier = Modifier) { + // The same instance the inner LockScreen gets: both come from this back-stack entry. + val canReopenNote = hiltViewModel()::canReopenNote val unlockReveal = LocalUnlockReveal.current val exits = remember(navController, unlockReveal) { LockExits( onUnlock = { isSessionLocked -> - navController.leaveLockScreen(unlockReveal, isSessionLocked, viewModel::canReopenNote) + navController.leaveLockScreen(unlockReveal, isSessionLocked, canReopenNote) }, // A recovery-phrase unlock means the PIN was forgotten: set a new one before going on. onRecoveryUnlock = { @@ -69,7 +67,7 @@ fun LockScreen( }, ) } - LockScreen(exits = exits, modifier = modifier, viewModel = viewModel) + LockScreen(exits = exits, modifier = modifier) } @Composable From 76f94afb75bf2af398f51f90fe9ac8fbb4bdc73a Mon Sep 17 00:00:00 2001 From: pasichdev Date: Mon, 5 Oct 2026 09:26:24 +0300 Subject: [PATCH 05/19] fix(handoff): trust the Play App Signing certificate of My Notes Builds of My Notes installed from Google Play are signed with the Play App Signing key, not with the release key of the GitHub and F-Droid builds (which Play only uses as the upload key). Pin both certificates, so the hand-off from the Play build is not refused as untrusted_caller. Refs: #46 --- .../encly/data/handoff/MyNotesCallerVerifier.kt | 11 +++++------ .../encly/data/handoff/MyNotesCallerVerifierTest.kt | 7 +++++-- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/com/pasich/encly/data/handoff/MyNotesCallerVerifier.kt b/app/src/main/java/com/pasich/encly/data/handoff/MyNotesCallerVerifier.kt index fc59fa4..eb5ea84 100644 --- a/app/src/main/java/com/pasich/encly/data/handoff/MyNotesCallerVerifier.kt +++ b/app/src/main/java/com/pasich/encly/data/handoff/MyNotesCallerVerifier.kt @@ -73,14 +73,13 @@ class MyNotesCallerVerifier( * SHA-256 (lower-case hex) of every certificate My Notes is signed with. A caller signed * with none of them is refused. * - * - `03f2b8c7…483f`: the release key of the GitHub / F-Droid builds (checked on the - * GitHub release APK 2.6.55, CN=Andrii Pasichnik12). - * - * TODO(pasichDev/Encly#46): before release, add the Google Play App Signing certificate - * SHA-256 of My Notes (Play Console -> My Notes -> Test and release -> App integrity -> - * App signing). Without it the Play build of My Notes is refused as untrusted_caller. + * - `fd25d0a0…81e3`: the Google Play App Signing key, which signs every build installed + * from Google Play (Play Console -> My Notes -> App signing). + * - `03f2b8c7…483f`: the release key of the GitHub / F-Droid builds, which is also the + * Play upload key (GitHub release APK 2.6.55, CN=Andrii Pasichnik12). */ val TRUSTED_MY_NOTES_CERT_SHA256: Set = setOf( + "fd25d0a05a29c7f294f5b6c4230b482e9a55aba89908d64f2015a4acfdee81e3", "03f2b8c7c96778b7efb80bb08af99b273a6c0c24e5864d0a211c3a8e3d02483f", ) } diff --git a/app/src/test/java/com/pasich/encly/data/handoff/MyNotesCallerVerifierTest.kt b/app/src/test/java/com/pasich/encly/data/handoff/MyNotesCallerVerifierTest.kt index 32f888f..ea8bee8 100644 --- a/app/src/test/java/com/pasich/encly/data/handoff/MyNotesCallerVerifierTest.kt +++ b/app/src/test/java/com/pasich/encly/data/handoff/MyNotesCallerVerifierTest.kt @@ -70,9 +70,12 @@ class MyNotesCallerVerifierTest { } @Test - fun theShippedPinIsTheGitHubReleaseCertificate() { + fun theShippedPinsAreThePlayAndGitHubReleaseCertificates() { assertEquals( - setOf("03f2b8c7c96778b7efb80bb08af99b273a6c0c24e5864d0a211c3a8e3d02483f"), + setOf( + "fd25d0a05a29c7f294f5b6c4230b482e9a55aba89908d64f2015a4acfdee81e3", + "03f2b8c7c96778b7efb80bb08af99b273a6c0c24e5864d0a211c3a8e3d02483f", + ), MyNotesCallerVerifier.TRUSTED_MY_NOTES_CERT_SHA256, ) MyNotesCallerVerifier.TRUSTED_MY_NOTES_CERT_SHA256.forEach { assertTrue(it.matches(Regex("[0-9a-f]{64}"))) } From bdba16cbd395b60bed9b8a566432593208c65802 Mon Sep 17 00:00:00 2001 From: pasichdev Date: Mon, 5 Oct 2026 11:25:45 +0300 Subject: [PATCH 06/19] feat(tasks): show each task's next step and open its sub-task tree in place On the Tasks list an open task with sub-tasks now shows its next step, the first open sub-task, as one tree leaf under it, plus a segment bar of its progress instead of a "2/5" count. Ticking the next step saves it at once and the following one slides up into its place; ticking (or deleting) the last open one offers "Complete task". A task without sub-tasks looks as before. A tap on the task opens the whole tree, done ones struck through, ending with an add leaf ("First step" on a task without any). In the tree a sub-task ticks in place, a tap on its title renames it, clearing the title or its cross deletes it with Undo, and a long press drags it into a new place, with Move up/down for TalkBack. Back closes an open field, then folds the tree. Open trees survive rotation, and an unsaved field is saved when the app goes to the background. The pencil is now the one way into the task sheet, where the sub-task block sits right under the description. Refs: #55 --- CHANGELOG.md | 18 +- .../encly/data/database/dao/TasksDao.kt | 40 +- .../com/pasich/encly/data/model/Subtask.kt | 3 - .../data/repository/TasksRepositoryImpl.kt | 24 +- .../domain/repository/TasksRepository.kt | 18 +- .../components/tasks/SubtaskTree.kt | 377 ++++++++++++++++ .../presentation/components/tiles/TaskItem.kt | 173 ++++++-- .../designsystem/SubtaskTreeRows.kt | 346 +++++++++++++++ .../encly/presentation/designsystem/Tasks.kt | 235 ++++++++-- .../dialogs/tasks/AddTaskDialog.kt | 3 +- .../dialogs/tasks/SubtaskEditor.kt | 1 + .../encly/presentation/screen/TasksList.kt | 237 ++++++++++ .../encly/presentation/screen/TasksScreen.kt | 224 +++------- .../viewmodel/InlineSubtaskEdit.kt | 21 + .../presentation/viewmodel/TasksViewModel.kt | 264 +++++++++++- app/src/main/res/values-de/strings.xml | 11 + app/src/main/res/values-es/strings.xml | 12 + app/src/main/res/values-fr/strings.xml | 12 + app/src/main/res/values-it/strings.xml | 12 + app/src/main/res/values-nl/strings.xml | 11 + app/src/main/res/values-pl/strings.xml | 13 + app/src/main/res/values-pt/strings.xml | 12 + app/src/main/res/values-uk/strings.xml | 13 + app/src/main/res/values/strings.xml | 12 +- .../encly/data/database/SubtasksDaoTest.kt | 81 +++- .../viewmodel/TasksViewModelDraftTest.kt | 13 +- .../TasksViewModelInlineSubtasksTest.kt | 406 ++++++++++++++++++ .../viewmodel/TasksViewModelSubtasksTest.kt | 82 +++- .../encly/testutil/TestTasksRepository.kt | 43 +- .../java/com/pasich/encly/ui/screens/Fakes.kt | 35 +- .../encly/ui/screens/TasksScreenTest.kt | 194 ++++++++- 31 files changed, 2667 insertions(+), 279 deletions(-) create mode 100644 app/src/main/java/com/pasich/encly/presentation/components/tasks/SubtaskTree.kt create mode 100644 app/src/main/java/com/pasich/encly/presentation/designsystem/SubtaskTreeRows.kt create mode 100644 app/src/main/java/com/pasich/encly/presentation/screen/TasksList.kt create mode 100644 app/src/main/java/com/pasich/encly/presentation/viewmodel/InlineSubtaskEdit.kt create mode 100644 app/src/test/java/com/pasich/encly/presentation/viewmodel/TasksViewModelInlineSubtasksTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index c356d3b..c770ca7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,11 +13,19 @@ IzzyOnDroid) and used as the GitHub Release notes. ### Added -- Sub-tasks: a task can have a checklist of sub-tasks (one level). Add, edit, tick, reorder - (drag or the accessibility actions) and delete them in the task sheet; the task tile shows the - progress, e.g. `2/5`. Completing a task never ticks its sub-tasks, and ticking the last open - sub-task does not complete the task on its own: a snackbar offers "Complete task". Deleting a - task deletes its sub-tasks, and Undo brings them back. +- Sub-tasks: a task can have a checklist of sub-tasks (one level). On the Tasks list an open + task shows its next step (its first open sub-task) as one leaf under it, and every task with + sub-tasks a segment bar of its progress; a task without sub-tasks looks as before. Ticking the + next step saves it at once and the following one slides up into its place. A tap on the task + (or the leaf's chevron) opens the whole tree, done ones struck through, ending with an add leaf + ("First step" on a task without any) whose field adds each title on Done and stays open for + the next. In the tree a sub-task ticks in place, a tap on its title renames it, clearing the + title or its ✕ deletes it with Undo, and a long press drags it into a new place (Move up/down + for TalkBack); Back closes the field, then folds the tree. The pencil opens the task sheet, + which still adds, edits, ticks, reorders and deletes them. Completing a task never ticks its + sub-tasks, and ticking the last open sub-task does not complete the task on its own: a + snackbar offers "Complete task". Deleting a task deletes its sub-tasks, and Undo brings them + back. - Encrypted backups carry sub-tasks (backup schema 3). Older backups (schema 1 and 2) still restore, with no sub-tasks. Merge import adds the sub-tasks a task already on the device is missing. diff --git a/app/src/main/java/com/pasich/encly/data/database/dao/TasksDao.kt b/app/src/main/java/com/pasich/encly/data/database/dao/TasksDao.kt index 5907afb..8dbcc0f 100644 --- a/app/src/main/java/com/pasich/encly/data/database/dao/TasksDao.kt +++ b/app/src/main/java/com/pasich/encly/data/database/dao/TasksDao.kt @@ -8,7 +8,6 @@ import androidx.room.Query import androidx.room.Transaction import androidx.room.Update import com.pasich.encly.data.model.Subtask -import com.pasich.encly.data.model.SubtaskProgress import com.pasich.encly.data.model.Task import kotlinx.coroutines.flow.Flow @@ -54,15 +53,44 @@ interface TasksDao { @Query("SELECT * FROM subtasks WHERE taskId = :taskId ORDER BY position, id") suspend fun getSubtasks(taskId: Long): List - /** Done and total sub-tasks per task; tasks without sub-tasks are absent. */ - @Query( - "SELECT taskId, SUM(isCompleted) AS done, COUNT(*) AS total FROM subtasks GROUP BY taskId", - ) - fun getSubtaskProgress(): Flow> + /** Every sub-task, grouped by task and in each task's order, for the Tasks list. */ + @Query("SELECT * FROM subtasks ORDER BY taskId, position, id") + fun observeSubtasks(): Flow> + + @Query("UPDATE subtasks SET isCompleted = :done WHERE id = :id") + suspend fun setSubtaskCompleted(id: Long, done: Boolean): Int @Insert(onConflict = OnConflictStrategy.ABORT) suspend fun insertSubtask(subtask: Subtask): Long + /** The position after the task's last sub-task; 0 when it has none. */ + @Query("SELECT COALESCE(MAX(position) + 1, 0) FROM subtasks WHERE taskId = :taskId") + suspend fun nextSubtaskPosition(taskId: Long): Int + + /** Adds a sub-task after the task's last one; its row id. */ + @Transaction + suspend fun appendSubtask(taskId: Long, title: String): Long = + insertSubtask(Subtask(taskId = taskId, title = title, position = nextSubtaskPosition(taskId))) + + @Query("UPDATE subtasks SET title = :title WHERE id = :id") + suspend fun renameSubtask(id: Long, title: String): Int + + @Query("DELETE FROM subtasks WHERE id = :id") + suspend fun deleteSubtaskById(id: Long): Int + + @Query("UPDATE subtasks SET position = position + 1 WHERE taskId = :taskId AND position >= :position") + suspend fun shiftSubtasksFrom(taskId: Long, position: Int): Int + + /** + * Puts a deleted sub-task back (undo) at its old position, with its uid: the rows from that + * position on move down one. It gets a new row id, as its old one may have been reused. + */ + @Transaction + suspend fun restoreSubtask(subtask: Subtask) { + shiftSubtasksFrom(subtask.taskId, subtask.position) + insertSubtask(subtask.copy(id = 0)) + } + @Update suspend fun updateSubtask(subtask: Subtask): Int diff --git a/app/src/main/java/com/pasich/encly/data/model/Subtask.kt b/app/src/main/java/com/pasich/encly/data/model/Subtask.kt index 11bdb1c..6778b51 100644 --- a/app/src/main/java/com/pasich/encly/data/model/Subtask.kt +++ b/app/src/main/java/com/pasich/encly/data/model/Subtask.kt @@ -33,6 +33,3 @@ data class Subtask( @ColumnInfo(defaultValue = "''") val uid: String = "", ) - -/** How many of a task's sub-tasks are done, for the `2/5` on its tile. */ -data class SubtaskProgress(val taskId: Long, val done: Int, val total: Int) diff --git a/app/src/main/java/com/pasich/encly/data/repository/TasksRepositoryImpl.kt b/app/src/main/java/com/pasich/encly/data/repository/TasksRepositoryImpl.kt index 4d332a2..55a8d1b 100644 --- a/app/src/main/java/com/pasich/encly/data/repository/TasksRepositoryImpl.kt +++ b/app/src/main/java/com/pasich/encly/data/repository/TasksRepositoryImpl.kt @@ -2,7 +2,6 @@ package com.pasich.encly.data.repository import com.pasich.encly.data.database.DatabaseProvider import com.pasich.encly.data.model.Subtask -import com.pasich.encly.data.model.SubtaskProgress import com.pasich.encly.data.model.Task import com.pasich.encly.domain.repository.TasksRepository import kotlinx.coroutines.flow.Flow @@ -48,7 +47,28 @@ class TasksRepositoryImpl @Inject constructor(private val databaseProvider: Data dao().deleteAllCompletedTasks() } - override fun getSubtaskProgress(): Flow> = daoFlow { dao().getSubtaskProgress() } + override fun observeSubtasks(): Flow> = daoFlow { dao().observeSubtasks() } + + override suspend fun setSubtaskCompleted(id: Long, done: Boolean): Result = + storageWrite(TAG, "setSubtaskCompleted") { + dao().setSubtaskCompleted(id, done).requireRows() + } + + override suspend fun addSubtask(taskId: Long, title: String): Result = storageWrite(TAG, "addSubtask") { + dao().appendSubtask(taskId, title) + } + + override suspend fun renameSubtask(id: Long, title: String): Result = storageWrite(TAG, "renameSubtask") { + dao().renameSubtask(id, title).requireRows() + } + + override suspend fun deleteSubtask(id: Long): Result = storageWrite(TAG, "deleteSubtask") { + dao().deleteSubtaskById(id).requireRows() + } + + override suspend fun restoreSubtask(subtask: Subtask): Result = storageWrite(TAG, "restoreSubtask") { + dao().restoreSubtask(subtask) + } override suspend fun getSubtasks(taskId: Long): Result> = storageWrite(TAG, "getSubtasks") { dao().getSubtasks(taskId) diff --git a/app/src/main/java/com/pasich/encly/domain/repository/TasksRepository.kt b/app/src/main/java/com/pasich/encly/domain/repository/TasksRepository.kt index 025405c..5f1bcbe 100644 --- a/app/src/main/java/com/pasich/encly/domain/repository/TasksRepository.kt +++ b/app/src/main/java/com/pasich/encly/domain/repository/TasksRepository.kt @@ -1,7 +1,6 @@ package com.pasich.encly.domain.repository import com.pasich.encly.data.model.Subtask -import com.pasich.encly.data.model.SubtaskProgress import com.pasich.encly.data.model.Task import kotlinx.coroutines.flow.Flow @@ -21,8 +20,21 @@ interface TasksRepository { suspend fun deleteTaskById(id: Long): Result suspend fun deleteAllCompletedTasks(): Result - /** Done/total sub-tasks of every task that has any. */ - fun getSubtaskProgress(): Flow> + /** Every sub-task, by task and in each task's order. */ + fun observeSubtasks(): Flow> + + /** Ticks or unticks one sub-task. */ + suspend fun setSubtaskCompleted(id: Long, done: Boolean): Result + + /** Adds a sub-task titled [title] after the task's last one; its row id. */ + suspend fun addSubtask(taskId: Long, title: String): Result + + suspend fun renameSubtask(id: Long, title: String): Result + + suspend fun deleteSubtask(id: Long): Result + + /** Undo of [deleteSubtask]: the same sub-task (uid, title, state) back at its position. */ + suspend fun restoreSubtask(subtask: Subtask): Result /** The task's sub-tasks in their order. */ suspend fun getSubtasks(taskId: Long): Result> diff --git a/app/src/main/java/com/pasich/encly/presentation/components/tasks/SubtaskTree.kt b/app/src/main/java/com/pasich/encly/presentation/components/tasks/SubtaskTree.kt new file mode 100644 index 0000000..b98aef4 --- /dev/null +++ b/app/src/main/java/com/pasich/encly/presentation/components/tasks/SubtaskTree.kt @@ -0,0 +1,377 @@ +package com.pasich.encly.presentation.components.tasks + +import android.provider.Settings +import androidx.activity.compose.BackHandler +import androidx.compose.animation.core.updateTransition +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.ime +import androidx.compose.foundation.layout.isImeVisible +import androidx.compose.foundation.relocation.BringIntoViewRequester +import androidx.compose.foundation.relocation.bringIntoViewRequester +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.CustomAccessibilityAction +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.customActions +import androidx.compose.ui.semantics.semantics +import com.pasich.encly.R +import com.pasich.encly.data.model.Subtask +import com.pasich.encly.presentation.designsystem.CheckboxSize +import com.pasich.encly.presentation.designsystem.EnclyAddSubtaskButton +import com.pasich.encly.presentation.designsystem.EnclyCheckbox +import com.pasich.encly.presentation.designsystem.EnclyExpandButton +import com.pasich.encly.presentation.designsystem.EnclyIcons +import com.pasich.encly.presentation.designsystem.EnclyRowIconButton +import com.pasich.encly.presentation.designsystem.EnclyStepTransition +import com.pasich.encly.presentation.designsystem.EnclySubtaskAddIcon +import com.pasich.encly.presentation.designsystem.EnclySubtaskField +import com.pasich.encly.presentation.designsystem.EnclySubtaskRow +import com.pasich.encly.presentation.viewmodel.InlineSubtaskEdit +import com.pasich.encly.presentation.viewmodel.InlineSubtaskTarget +import kotlinx.coroutines.delay +import sh.calvin.reorderable.ReorderableColumn + +/** What the list's inline sub-task field reports back (see TasksViewModel). */ +@Immutable +class InlineSubtaskActions( + val onTextChange: (String) -> Unit, + /** The keyboard's Done. */ + val onDone: () -> Unit, + /** Focus left the field for [InlineSubtaskTarget]: save it and close. */ + val onClose: (InlineSubtaskTarget) -> Unit, + /** The ✕ of a sub-task being renamed. */ + val onDelete: () -> Unit, +) + +/** What a task's sub-tasks can do on the list. */ +@Immutable +class SubtaskTreeActions( + val onToggle: (Subtask, Boolean) -> Unit, + val onStartAdding: (taskId: Long) -> Unit, + val onStartRenaming: (Subtask) -> Unit, + /** Drag or "Move up/down" within the task's tree. */ + val onMove: (taskId: Long, from: Int, to: Int) -> Unit, + /** The chevron: opens the whole tree, or folds it back to the next step. */ + val onToggleTree: (taskId: Long) -> Unit, + val inline: InlineSubtaskActions, +) + +/** How long a ticked next step stays, struck through, before the following one slides up. */ +private const val NEXT_STEP_HOLD_MS = 600L + +/** + * A task's next step while its tree is folded: its first open sub-task as one leaf, with the + * chevron that opens the whole tree. Ticked, it stays struck through for a moment and then the + * following open one slides up into its place (at once with animations off); taps on it wait + * until that is over, so a double tap cannot tick the new one. With every sub-task done it shows + * the one ticked last (or the last one), struck through and dimmed. [subtasks] is not empty. + */ +@Composable +internal fun NextStepLeaf( + taskId: Long, + subtasks: List, + actions: SubtaskTreeActions, + modifier: Modifier = Modifier, + enabled: Boolean = true, +) { + val animate = rememberAnimationsEnabled() + val firstOpen = subtasks.firstOrNull { !it.isCompleted } + var shownId by remember(taskId) { mutableLongStateOf(firstOpen?.id ?: subtasks.last().id) } + val targetId = firstOpen?.id ?: subtasks.find { it.id == shownId }?.id ?: subtasks.last().id + val currentSubtasks by rememberUpdatedState(subtasks) + LaunchedEffect(targetId) { + if (targetId == shownId) return@LaunchedEffect + val leaving = currentSubtasks.find { it.id == shownId } + if (animate && leaving?.isCompleted == true) delay(NEXT_STEP_HOLD_MS) + shownId = targetId + } + val step = updateTransition(targetState = shownId, label = "nextStep") + val moving = shownId != targetId || step.currentState != step.targetState + val expand = stringResource(R.string.subtasks_show) + EnclyStepTransition(step = step, animate = animate, modifier = modifier) { id -> + val subtask = subtasks.find { it.id == id } + if (subtask != null) { + EnclySubtaskRow( + title = subtask.title, + checked = subtask.isCompleted, + onCheckedChange = { actions.onToggle(subtask, it) }, + isLast = true, + dimmed = firstOpen == null, + enabled = enabled && !moving, + onClick = { actions.onToggleTree(taskId) }, + onClickLabel = expand, + checkboxDescription = stringResource(R.string.subtask_next_step, subtask.title), + trailing = { + EnclyExpandButton(expanded = false, contentDescription = expand, onClick = { + actions.onToggleTree(taskId) + }) + }, + ) + } + } +} + +/** False when the system's animator duration scale is 0 (animations turned off). */ +@Composable +private fun rememberAnimationsEnabled(): Boolean { + val resolver = LocalContext.current.contentResolver + return remember(resolver) { + Settings.Global.getFloat(resolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1f) != 0f + } +} + +/** + * A task's whole tree: each of [subtasks] in order (tick in place; a tap on the title renames it + * inline; long-press drags it within the tree, with "Move up/down" for TalkBack), and always last + * the add leaf, which turns into the inline field for a new one. A task without sub-tasks shows + * only that leaf, as "First step". The first row carries the chevron that folds the tree back. + * [edit] is the list's inline field when it belongs to this task; [dimmed] for a task that is done. + */ +@Composable +internal fun SubtaskTree( + taskId: Long, + subtasks: List, + edit: InlineSubtaskEdit?, + actions: SubtaskTreeActions, + modifier: Modifier = Modifier, + dimmed: Boolean = false, + enabled: Boolean = true, +) { + val haptics = LocalHapticFeedback.current + // Shows a dropped row in its new place until the stored order comes back. + var rows by remember(subtasks) { mutableStateOf(subtasks) } + val collapse = stringResource(R.string.subtasks_hide) + val chevron: @Composable () -> Unit = { + EnclyExpandButton(expanded = true, contentDescription = collapse, onClick = { actions.onToggleTree(taskId) }) + } + val move = { from: Int, to: Int -> + rows = rows.toMutableList().apply { add(to, removeAt(from)) } + actions.onMove(taskId, from, to) + } + Column(modifier = modifier) { + ReorderableColumn( + list = rows, + onSettle = move, + onMove = { haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove) }, + ) { index, subtask, dragging -> + key(subtask.id) { + val renaming = edit != null && edit.target == InlineSubtaskTarget.Rename(taskId, subtask.id) + val reorder = Modifier + .longPressDraggableHandle( + enabled = enabled && !renaming, + onDragStarted = { haptics.performHapticFeedback(HapticFeedbackType.LongPress) }, + ) + .moveActions( + moveUp = { move(index, index - 1) }.takeIf { index > 0 }, + moveDown = { move(index, index + 1) }.takeIf { index < rows.lastIndex }, + ) + if (renaming) { + RenameField(subtask, edit, actions, enabled) + } else { + EnclySubtaskRow( + title = subtask.title, + checked = subtask.isCompleted, + onCheckedChange = { actions.onToggle(subtask, it) }, + // The add leaf always closes the tree. + isLast = false, + modifier = reorder, + dimmed = dimmed, + enabled = enabled, + onClick = { actions.onStartRenaming(subtask) }, + onClickLabel = stringResource(R.string.edit), + dragging = dragging, + trailing = chevron.takeIf { index == 0 }, + ) + } + } + } + AddLeaf( + taskId, + edit, + actions, + enabled, + first = subtasks.isEmpty(), + trailing = chevron.takeIf { + subtasks.isEmpty() + }, + ) + } +} + +/** "Move up" and "Move down" for TalkBack, where the row can move that way. */ +@Composable +private fun Modifier.moveActions(moveUp: (() -> Unit)?, moveDown: (() -> Unit)?): Modifier { + val up = stringResource(R.string.tag_move_up) + val down = stringResource(R.string.tag_move_down) + return semantics { + customActions = listOfNotNull( + moveUp?.let { action -> + CustomAccessibilityAction(up) { + action() + true + } + }, + moveDown?.let { action -> + CustomAccessibilityAction(down) { + action() + true + } + }, + ) + } +} + +/** The add leaf (└ + Sub-task, or + First step), or the inline field it turned into. */ +@Suppress("LongParameterList") // The leaf's state and callbacks, split out of SubtaskTree. +@Composable +private fun AddLeaf( + taskId: Long, + edit: InlineSubtaskEdit?, + actions: SubtaskTreeActions, + enabled: Boolean, + first: Boolean, + trailing: (@Composable () -> Unit)?, +) { + val label = stringResource(if (first) R.string.subtask_first_step else R.string.subtask_placeholder) + if (edit != null && edit.target == InlineSubtaskTarget.Add(taskId)) { + InlineSubtaskField(edit = edit, label = label, actions = actions.inline) + } else { + EnclyAddSubtaskButton( + text = label, + onClick = { actions.onStartAdding(taskId) }, + description = stringResource(R.string.subtask_add), + enabled = enabled, + trailing = trailing, + ) + } +} + +/** A sub-task row while it is renamed: its checkbox, the field with its title, and ✕ to delete it. */ +@Composable +private fun RenameField(subtask: Subtask, edit: InlineSubtaskEdit, actions: SubtaskTreeActions, enabled: Boolean) { + InlineSubtaskField( + edit = edit, + label = stringResource(R.string.subtask_placeholder), + actions = actions.inline, + isLast = false, + done = subtask.isCompleted, + leading = { + EnclyCheckbox( + checked = subtask.isCompleted, + onCheckedChange = { actions.onToggle(subtask, it) }, + size = CheckboxSize.SMALL, + enabled = enabled, + modifier = Modifier.semantics { contentDescription = subtask.title }, + ) + }, + trailing = { + EnclyRowIconButton( + icon = EnclyIcons.Close, + contentDescription = stringResource(R.string.subtask_delete), + onClick = actions.inline.onDelete, + ) + }, + ) +} + +/** + * The list's one inline sub-task field. It takes focus (and the keyboard) when it appears and + * stays in view above the keyboard. It closes, saving through [InlineSubtaskActions.onClose], + * when it loses focus, on Back, or when the keyboard is hidden while it is focused. + */ +@OptIn(ExperimentalFoundationApi::class) +@Suppress("LongParameterList") // EnclySubtaskField's slots, passed through. +@Composable +private fun InlineSubtaskField( + edit: InlineSubtaskEdit, + label: String, + actions: InlineSubtaskActions, + modifier: Modifier = Modifier, + isLast: Boolean = true, + done: Boolean = false, + leading: (@Composable () -> Unit)? = null, + trailing: (@Composable () -> Unit)? = null, +) { + val target = edit.target + val focusRequester = remember { FocusRequester() } + val bringIntoView = remember { BringIntoViewRequester() } + var focused by remember { mutableStateOf(false) } + val currentActions by rememberUpdatedState(actions) + val close = { currentActions.onClose(target) } + + LaunchedEffect(Unit) { focusRequester.requestFocus() } + BackHandler(onBack = close) + CloseWhenKeyboardHides(focused = focused, onClose = close) + // The list is ime-padded: once the keyboard is up (or its height changes), scroll to the field. + val keyboardHeight = WindowInsets.ime.getBottom(LocalDensity.current) + LaunchedEffect(focused, keyboardHeight) { + if (focused) bringIntoView.bringIntoView() + } + + EnclySubtaskField( + value = edit.text, + onValueChange = actions.onTextChange, + label = label, + isLast = isLast, + onDone = actions.onDone, + modifier = modifier.bringIntoViewRequester(bringIntoView), + fieldModifier = Modifier + .focusRequester(focusRequester) + .onFocusChanged { state -> + if (state.isFocused) { + focused = true + } else if (focused) { + focused = false + close() + } + }, + done = done, + leading = leading ?: { EnclySubtaskAddIcon() }, + trailing = trailing, + ) +} + +/** + * Calls [onClose] when the keyboard goes away while the field is [focused]: Back (which the + * keyboard takes first) or its own hide key. Only after it has been seen open, so a hardware + * keyboard (never shown) does not close the field. + */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun CloseWhenKeyboardHides(focused: Boolean, onClose: () -> Unit) { + val keyboardVisible = WindowInsets.isImeVisible + var seenOpen by remember { mutableStateOf(false) } + val currentOnClose by rememberUpdatedState(onClose) + LaunchedEffect(focused, keyboardVisible) { + when { + !focused -> seenOpen = false + + keyboardVisible -> seenOpen = true + + seenOpen -> { + seenOpen = false + currentOnClose() + } + } + } +} diff --git a/app/src/main/java/com/pasich/encly/presentation/components/tiles/TaskItem.kt b/app/src/main/java/com/pasich/encly/presentation/components/tiles/TaskItem.kt index dff2511..b268ae4 100644 --- a/app/src/main/java/com/pasich/encly/presentation/components/tiles/TaskItem.kt +++ b/app/src/main/java/com/pasich/encly/presentation/components/tiles/TaskItem.kt @@ -2,7 +2,10 @@ package com.pasich.encly.presentation.components.tiles import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween +import androidx.compose.animation.core.updateTransition +import androidx.compose.foundation.layout.Column import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -11,33 +14,63 @@ import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.CustomAccessibilityAction +import androidx.compose.ui.semantics.customActions +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription import com.pasich.encly.R -import com.pasich.encly.data.model.SubtaskProgress +import com.pasich.encly.data.model.Subtask import com.pasich.encly.data.model.Task +import com.pasich.encly.presentation.components.tasks.NextStepLeaf import com.pasich.encly.presentation.components.tasks.PriorityValues +import com.pasich.encly.presentation.components.tasks.SubtaskTree +import com.pasich.encly.presentation.components.tasks.SubtaskTreeActions +import com.pasich.encly.presentation.designsystem.EnclyIcons +import com.pasich.encly.presentation.designsystem.EnclyRowIconButton +import com.pasich.encly.presentation.designsystem.EnclySubtaskTree import com.pasich.encly.presentation.designsystem.EnclyTaskRow +import com.pasich.encly.presentation.viewmodel.InlineSubtaskEdit import com.pasich.encly.utils.rememberDateTimeFormat import kotlinx.coroutines.delay import java.util.Date private const val TASK_REMOVAL_ANIMATION_MS = 400 private const val TASK_TRANSLATION_X = 100f -private const val META_SEPARATOR = " · " -private data class TaskCardState(val enabled: Boolean, val isRemoving: Boolean, val animationProgress: Float) +/** + * What a task's block on the Tasks list can do; the screen builds it once from its ViewModel. + * A tap on the row opens or folds its sub-task tree ([SubtaskTreeActions.onToggleTree]). + */ +@Immutable +class TaskItemActions( + val onToggleTask: (taskId: Long, completed: Boolean) -> Unit, + /** The pencil: opens the task sheet, the only way to it from the list. */ + val onEdit: (Task) -> Unit, + val tree: SubtaskTreeActions, +) -private data class TaskCardActions(val onClick: () -> Unit, val onComplete: () -> Unit, val onUndo: () -> Unit) +private data class TaskCardState(val enabled: Boolean, val isRemoving: Boolean, val animationProgress: Float) +/** + * A task and its sub-tasks. Folded: an open task shows its next step as one leaf + * ([NextStepLeaf]) and every task with sub-tasks a segment bar of their progress; a task + * without sub-tasks looks like a plain task. [expanded]: the whole tree ([SubtaskTree]). A tap on + * the row opens or folds it; the pencil opens the task sheet. The block is one list item, so it + * fades out as a whole when the task is completed. [inlineEdit] is the list's inline sub-task + * field when it belongs to this task. + */ @Composable fun TaskItem( task: Task, - onTaskToggle: (Long, Boolean) -> Unit, + subtasks: List, + expanded: Boolean, + actions: TaskItemActions, modifier: Modifier = Modifier, - onTaskClick: ((Task) -> Unit)? = null, + inlineEdit: InlineSubtaskEdit? = null, enabled: Boolean = true, - /** Shown as `2/5` when the task has sub-tasks. */ - subtasks: SubtaskProgress? = null, ) { var isRemoving by remember(task.id) { mutableStateOf(false) } var shouldComplete by remember(task.id) { mutableStateOf(false) } @@ -52,7 +85,7 @@ fun TaskItem( shouldComplete = shouldComplete, onRemovingChange = { isRemoving = it }, onCompleteChange = { shouldComplete = it }, - onTaskToggle = onTaskToggle, + onTaskToggle = actions.onToggleTask, ) val state = TaskCardState( @@ -60,12 +93,44 @@ fun TaskItem( isRemoving = isRemoving, animationProgress = animationProgress, ) - val actions = TaskCardActions( - onClick = { onTaskClick?.invoke(task) }, - onComplete = { shouldComplete = true }, - onUndo = { onTaskToggle(task.id, false) }, - ) - TaskCard(task, subtasks, state, actions, modifier) + val tree = updateTransition(targetState = expanded, label = "subtasks") + val enabledNow = state.enabled && !state.isRemoving + // Done tasks show only the bar while folded. + val showsNextStep = !task.isCompleted && subtasks.isNotEmpty() + Column( + modifier = modifier.graphicsLayer { + alpha = state.animationProgress + scaleX = state.animationProgress + scaleY = state.animationProgress + translationX = (1f - state.animationProgress) * TASK_TRANSLATION_X + }, + ) { + TaskRow( + task = task, + subtasks = subtasks, + expanded = expanded, + // The trunk stays drawn until the tree has fully folded. + connectorBelow = showsNextStep || tree.currentState || tree.targetState, + enabled = enabledNow, + actions = actions, + onComplete = { shouldComplete = true }, + ) + EnclySubtaskTree( + expanded = tree, + collapsed = { + if (showsNextStep) NextStepLeaf(task.id, subtasks, actions.tree, enabled = enabledNow) + }, + ) { + SubtaskTree( + taskId = task.id, + subtasks = subtasks, + edit = inlineEdit, + actions = actions.tree, + dimmed = task.isCompleted, + enabled = enabledNow, + ) + } + } } @Composable @@ -91,44 +156,82 @@ private fun CompleteTaskAfterAnimation( } } +/** + * The task's own row: checkbox (completes it after the removal animation, or reopens it), the + * text with the segment bar of its sub-tasks, and the pencil. A tap elsewhere opens or folds the + * tree. For TalkBack the row is one node that reads the title, priority and how many sub-tasks + * are done, with Expand/Collapse, Edit and Add sub-task as actions. + */ +@Suppress("LongParameterList", "LongMethod") // The row's state, callbacks and semantics, split out of TaskItem. @Composable -private fun TaskCard( +private fun TaskRow( task: Task, - subtasks: SubtaskProgress?, - state: TaskCardState, - actions: TaskCardActions, - modifier: Modifier = Modifier, + subtasks: List, + expanded: Boolean, + connectorBelow: Boolean, + enabled: Boolean, + actions: TaskItemActions, + onComplete: () -> Unit, ) { val dateFormat = rememberDateTimeFormat() + val focusManager = LocalFocusManager.current val priority = PriorityValues.getById(task.priority) val completedAt = task.completedDate?.takeIf { task.isCompleted }?.let { stringResource(R.string.task_completed_at, dateFormat.format(Date(it))) } - val progress = subtasks?.takeIf { it.total > 0 }?.let { - stringResource(R.string.subtask_progress, it.done, it.total) - } + val done = subtasks.count { it.isCompleted } + val summary = pluralStringResource(R.plurals.subtasks_done_summary, subtasks.size, done, subtasks.size) + .takeIf { subtasks.isNotEmpty() } + val toggleLabel = stringResource(if (expanded) R.string.subtasks_hide else R.string.subtasks_show) + val stateLabel = stringResource(if (expanded) R.string.subtasks_state_shown else R.string.subtasks_state_hidden) + val editLabel = stringResource(R.string.task_edit_placeholder) + val addLabel = stringResource(R.string.subtask_add) + val toggle = { actions.tree.onToggleTree(task.id) } EnclyTaskRow( title = task.title, checked = task.isCompleted, onCheckedChange = { checked -> + // Saves and closes an inline sub-task field first, as any other tap on the list does. + focusManager.clearFocus() when { - checked && !task.isCompleted -> actions.onComplete() - !checked && task.isCompleted -> actions.onUndo() + checked && !task.isCompleted -> onComplete() + !checked && task.isCompleted -> actions.onToggleTask(task.id, false) } }, description = task.description, - meta = listOfNotNull(progress, completedAt).joinToString(META_SEPARATOR).ifEmpty { null }, + meta = completedAt, priority = stringResource(priority.label).takeIf { !task.isCompleted }, priorityEmphasis = priority.emphasis, large = true, - enabled = state.enabled && !state.isRemoving, - // Completed tasks open too, to read or edit them. - onClick = actions.onClick, - modifier = modifier.graphicsLayer { - alpha = state.animationProgress - scaleX = state.animationProgress - scaleY = state.animationProgress - translationX = (1f - state.animationProgress) * TASK_TRANSLATION_X + enabled = enabled, + // Done tasks open too: their sub-tasks can still be ticked, renamed and added. + onClick = toggle, + onClickLabel = toggleLabel, + connectorBelow = connectorBelow, + progress = subtasks.map { it.isCompleted }.takeIf { it.isNotEmpty() }, + modifier = Modifier.semantics { + stateDescription = listOfNotNull(summary, stateLabel).joinToString(", ") + customActions = listOf( + CustomAccessibilityAction(toggleLabel) { + toggle() + true + }, + CustomAccessibilityAction(editLabel) { + actions.onEdit(task) + true + }, + CustomAccessibilityAction(addLabel) { + actions.tree.onStartAdding(task.id) + true + }, + ) }, - ) + ) { + EnclyRowIconButton( + icon = EnclyIcons.Edit, + contentDescription = editLabel, + onClick = { actions.onEdit(task) }, + enabled = enabled, + ) + } } diff --git a/app/src/main/java/com/pasich/encly/presentation/designsystem/SubtaskTreeRows.kt b/app/src/main/java/com/pasich/encly/presentation/designsystem/SubtaskTreeRows.kt new file mode 100644 index 0000000..d4c5829 --- /dev/null +++ b/app/src/main/java/com/pasich/encly/presentation/designsystem/SubtaskTreeRows.kt @@ -0,0 +1,346 @@ +package com.pasich.encly.presentation.designsystem + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.SizeTransform +import androidx.compose.animation.core.Transition +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.LayoutDirection +import com.pasich.encly.ui.theme.EnclyTheme + +/** How long the next step takes to slide into place. */ +private const val STEP_ANIMATION_MS = 250 + +/** + * The sub-tasks under a task's [EnclyTaskRow]: [collapsed] (its next step, or nothing) while + * [expanded] (an `updateTransition` of the open state) is false, the whole tree ([content]) while + * it is true. They cross-fade while the height follows. While it moves either way + * `currentState || targetState` is true, so the task row can keep its connector until the + * tree has fully closed. + */ +@Composable +fun EnclySubtaskTree( + expanded: Transition, + collapsed: @Composable () -> Unit, + modifier: Modifier = Modifier, + content: @Composable () -> Unit, +) { + expanded.AnimatedContent( + modifier = modifier, + contentAlignment = Alignment.TopStart, + transitionSpec = { + fadeIn(tween(TREE_ANIMATION_MS)) togetherWith fadeOut(tween(TREE_ANIMATION_MS)) using + SizeTransform(clip = true) { _, _ -> tween(TREE_ANIMATION_MS) } + }, + ) { open -> + Column { if (open) content() else collapsed() } + } +} + +/** + * The next-step leaf as it moves on: when [step] (an `updateTransition` of the shown sub-task's + * id) changes, the new one slides up into place while the old one leaves upwards. Instant when + * not [animate] (animations turned off). + */ +@Composable +fun EnclyStepTransition( + step: Transition, + animate: Boolean, + modifier: Modifier = Modifier, + content: @Composable (Long) -> Unit, +) { + step.AnimatedContent( + modifier = modifier, + contentAlignment = Alignment.TopStart, + transitionSpec = { + if (!animate) { + EnterTransition.None togetherWith ExitTransition.None + } else { + (slideInVertically(tween(STEP_ANIMATION_MS)) { it } + fadeIn(tween(STEP_ANIMATION_MS))) togetherWith + (slideOutVertically(tween(STEP_ANIMATION_MS)) { -it } + fadeOut(tween(STEP_ANIMATION_MS))) using + SizeTransform(clip = true) + } + }, + ) { id -> content(id) } +} + +/** + * A sub-task in its task's tree: a [CheckboxSize.SMALL] checkbox and a bodyMedium title, struck + * through and muted when done, and an optional [trailing] action. [dimmed] mutes the title of a + * done task's sub-task. The checkbox announces [checkboxDescription] (the title by default); + * [onClick] (the title) is announced with [onClickLabel]. While [dragging] the row is lifted on + * `surfaceContainerHigh` and leaves its connector behind. + */ +@Suppress("LongParameterList") // A row's content, state and callbacks, like EnclyTaskRow. +@Composable +fun EnclySubtaskRow( + title: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + isLast: Boolean, + modifier: Modifier = Modifier, + dimmed: Boolean = false, + enabled: Boolean = true, + onClick: (() -> Unit)? = null, + onClickLabel: String? = null, + checkboxDescription: String = title, + dragging: Boolean = false, + trailing: (@Composable () -> Unit)? = null, +) { + val colors = MaterialTheme.colorScheme + val spacing = EnclyTheme.spacing + SubtaskTreeRow( + isLast = isLast, + connector = !dragging, + modifier = modifier.then( + if (dragging) Modifier.background(colors.surfaceContainerHigh, RoundedCornerShape(spacing.s)) else Modifier, + ), + ) { + EnclyCheckbox( + checked = checked, + onCheckedChange = onCheckedChange, + size = CheckboxSize.SMALL, + enabled = enabled, + modifier = Modifier.semantics { contentDescription = checkboxDescription }, + ) + Box( + contentAlignment = Alignment.CenterStart, + modifier = Modifier + .weight(1f) + .heightIn(min = spacing.minTouchTarget) + .then( + if (onClick != null) { + Modifier.clickable(onClickLabel = onClickLabel, onClick = onClick) + } else { + Modifier + }, + ) + .padding(start = spacing.xxs, end = spacing.xs), + ) { + Text( + text = title, + style = MaterialTheme.typography.bodyMedium.copy( + textDecoration = if (checked) TextDecoration.LineThrough else TextDecoration.None, + ), + color = if (checked || dimmed) colors.onSurfaceVariant else colors.onSurface, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + trailing?.invoke() + } +} + +/** The plus of a new sub-task, in the tree's checkbox column: 20 dp, `primary`. Decorative. */ +@Composable +fun EnclySubtaskAddIcon(modifier: Modifier = Modifier, enabled: Boolean = true) { + val tint = MaterialTheme.colorScheme.primary + Box(contentAlignment = Alignment.Center, modifier = modifier.size(EnclyTheme.spacing.minTouchTarget)) { + Icon( + EnclyIcons.Plus, + contentDescription = null, + tint = if (enabled) tint else tint.disabled(), + modifier = Modifier.size(EnclyTheme.spacing.iconSmall), + ) + } +} + +/** + * The add leaf closing a task's tree (└): [EnclySubtaskAddIcon] and [text] in labelLarge + * `primary`, announced as [description] (e.g. "Add sub-task" for a "Sub-task" label), and an + * optional [trailing] action. The row past the indent is the tap target. + */ +@Composable +fun EnclyAddSubtaskButton( + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + description: String = text, + enabled: Boolean = true, + trailing: (@Composable () -> Unit)? = null, +) { + val color = MaterialTheme.colorScheme.primary + SubtaskTreeRow(isLast = true, modifier = modifier) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .weight(1f) + .heightIn(min = EnclyTheme.spacing.minTouchTarget) + .clickable(enabled = enabled, role = Role.Button, onClick = onClick) + .semantics(mergeDescendants = true) { contentDescription = description }, + ) { + EnclySubtaskAddIcon(enabled = enabled) + Text( + text = text, + style = MaterialTheme.typography.labelLarge, + color = if (enabled) color else color.disabled(), + modifier = Modifier + .weight(1f) + .padding(start = EnclyTheme.spacing.xxs, end = EnclyTheme.spacing.xs), + ) + } + trailing?.invoke() + } +} + +/** + * A sub-task being typed in its task's tree (├, or └ when [isLast]): [leading] in the checkbox + * column (the sub-task's checkbox, or [EnclySubtaskAddIcon] for a new one), a single-line + * bodyMedium field underlined in `primary`, and an optional [trailing] action. [label] is the + * placeholder and the field's spoken name. The keyboard is the one of the task sheet's fields, + * with a Done key ([onDone]). [fieldModifier] reaches the text field itself (focus). + */ +@Suppress("LongParameterList") // A field's value, callbacks and slots, like EnclyTextField. +@Composable +fun EnclySubtaskField( + value: String, + onValueChange: (String) -> Unit, + label: String, + isLast: Boolean, + onDone: () -> Unit, + modifier: Modifier = Modifier, + fieldModifier: Modifier = Modifier, + done: Boolean = false, + leading: @Composable () -> Unit = { EnclySubtaskAddIcon() }, + trailing: (@Composable () -> Unit)? = null, +) { + val colors = MaterialTheme.colorScheme + val spacing = EnclyTheme.spacing + val style = MaterialTheme.typography.bodyMedium.copy( + color = if (done) colors.onSurfaceVariant else colors.onSurface, + textDecoration = if (done) TextDecoration.LineThrough else TextDecoration.None, + ) + SubtaskTreeRow(isLast = isLast, modifier = modifier) { + leading() + BasicTextField( + value = value, + onValueChange = onValueChange, + textStyle = style, + singleLine = true, + cursorBrush = SolidColor(colors.primary), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { onDone() }), + modifier = Modifier + .weight(1f) + .then(fieldModifier) + .semantics { contentDescription = label }, + decorationBox = { innerTextField -> + Box( + contentAlignment = Alignment.CenterStart, + modifier = Modifier + .heightIn(min = spacing.minTouchTarget) + .padding(start = spacing.xxs, end = spacing.xs), + ) { + Box(modifier = Modifier.fillMaxWidth().underline(colors.primary).padding(vertical = spacing.xxs)) { + if (value.isEmpty()) { + Text(text = label, style = style.copy(color = colors.onSurfaceVariant), maxLines = 1) + } + innerTextField() + } + } + }, + ) + trailing?.invoke() + } +} + +/** + * One row of a sub-task tree: at least a touch target tall, indented by one touch target, with + * the decorative [connector] (├, or └ when [isLast]) in that indent. + */ +@Composable +private fun SubtaskTreeRow( + isLast: Boolean, + modifier: Modifier = Modifier, + connector: Boolean = true, + content: @Composable RowScope.() -> Unit, +) { + val spacing = EnclyTheme.spacing + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier + .fillMaxWidth() + .heightIn(min = spacing.minTouchTarget) + .then( + if (connector) { + Modifier.subtaskConnector(isLast = isLast, color = MaterialTheme.colorScheme.outlineVariant) + } else { + Modifier + }, + ) + .padding(start = spacing.minTouchTarget), + content = content, + ) +} + +/** A [EnclyTheme.spacing] `stroke` line under the content, edge to edge. */ +@Composable +private fun Modifier.underline(color: Color): Modifier { + val stroke = EnclyTheme.spacing.stroke + return drawBehind { + val y = size.height - stroke.toPx() / 2 + drawLine(color, Offset(0f, y), Offset(size.width, y), stroke.toPx()) + } +} + +/** + * The tree connector behind a sub-task tree row: a vertical line under the parent's checkbox + * centre (half a touch target in from the start edge) through the row, or down to its middle on + * the last row, and an elbow to just before the small checkbox. Drawn only: it has no semantics. + * Mirrored in RTL. + */ +@Composable +private fun Modifier.subtaskConnector(isLast: Boolean, color: Color): Modifier { + val spacing = EnclyTheme.spacing + val rtl = LocalLayoutDirection.current == LayoutDirection.Rtl + return drawBehind { + val stroke = spacing.hairline.toPx() + val target = spacing.minTouchTarget.toPx() + val trunk = target / 2 + // The small checkbox is centred in its own touch target, which starts one target in. + val elbowEnd = target + (target - CheckboxSize.SMALL.size.toPx()) / 2 - spacing.xxs.toPx() + val middle = size.height / 2 + fun x(fromStart: Float) = if (rtl) size.width - fromStart else fromStart + drawLine(color, Offset(x(trunk), 0f), Offset(x(trunk), if (isLast) middle else size.height), stroke) + drawLine(color, Offset(x(trunk), middle), Offset(x(elbowEnd), middle), stroke) + } +} diff --git a/app/src/main/java/com/pasich/encly/presentation/designsystem/Tasks.kt b/app/src/main/java/com/pasich/encly/presentation/designsystem/Tasks.kt index db83f9a..2e32772 100644 --- a/app/src/main/java/com/pasich/encly/presentation/designsystem/Tasks.kt +++ b/app/src/main/java/com/pasich/encly/presentation/designsystem/Tasks.kt @@ -1,7 +1,9 @@ package com.pasich.encly.presentation.designsystem import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween +import androidx.compose.foundation.Canvas import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable @@ -9,14 +11,18 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.selection.toggleable import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.minimumInteractiveComponentSize @@ -24,16 +30,39 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import com.pasich.encly.ui.theme.EnclyTheme private const val CONTROL_ANIMATION_MS = 150 +/** How long a sub-task tree takes to open or close, and its chevron to turn. */ +internal const val TREE_ANIMATION_MS = 200 +private const val CHEVRON_TURN = 180f + +private val SEGMENT_BAR_MAX_WIDTH = 160.dp +private val SEGMENT_GAP = 3.dp +private val SEGMENT_MIN_GAP = 1.dp +private val SEGMENT_DONE_HEIGHT = 4.dp +private val SEGMENT_OPEN_HEIGHT = 2.dp + +/** Up to this many segments keep the full gap; more narrow it. */ +private const val SEGMENT_FULL_GAP_COUNT = 12 + /** Checkbox sizes: the task widget (22, radius 6) and full lists and the editor (24, radius 7). */ enum class CheckboxSize(val size: Dp, val radius: Dp) { SMALL(22.dp, 6.dp), @@ -94,9 +123,14 @@ fun EnclyCheckbox( /** * A task row: checkbox, title (struck through and muted when done), an optional description, a - * [meta] line (e.g. when it was completed) and the priority label in the data style - * ([priorityEmphasis] tints it `error`). [large] uses the 24 dp checkbox and bodyLarge of full - * lists; otherwise the compact widget row. + * [meta] line (e.g. when it was completed), the priority label in the data style + * ([priorityEmphasis] tints it `error`) and an optional [trailing] slot (the list's chevron and + * edit button) and, with [progress] (each sub-task's done state), an [EnclySegmentBar] under the + * meta line. [large] uses the 24 dp checkbox and bodyLarge of full lists; otherwise the compact + * widget row. With [onClick] the rest of the row is one tap target, announced with + * [onClickLabel]; the checkbox announces [title]. [connectorBelow] draws the start of the + * sub-task tree from under the checkbox to the row's bottom edge, where the first tree row + * ([EnclySubtaskRow], [EnclySubtaskField], [EnclyAddSubtaskButton]) continues it. */ @Composable fun EnclyTaskRow( @@ -111,45 +145,46 @@ fun EnclyTaskRow( large: Boolean = false, enabled: Boolean = true, onClick: (() -> Unit)? = null, + onClickLabel: String? = null, + connectorBelow: Boolean = false, + progress: List? = null, + trailing: (@Composable RowScope.() -> Unit)? = null, ) { val colors = MaterialTheme.colorScheme - val done = if (checked) TextDecoration.LineThrough else TextDecoration.None + val checkboxSize = if (large) CheckboxSize.LARGE else CheckboxSize.SMALL Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.xxs), modifier = modifier .fillMaxWidth() .heightIn(min = EnclyTheme.spacing.textButtonHeight) - .then(if (onClick != null) Modifier.clickable(enabled = enabled, onClick = onClick) else Modifier), + .then( + if (connectorBelow) Modifier.connectorBelowCheckbox(checkboxSize, colors.outlineVariant) else Modifier, + ) + .then( + if (onClick != null) { + Modifier.clickable(enabled = enabled, onClickLabel = onClickLabel, onClick = onClick) + } else { + Modifier + }, + ), ) { EnclyCheckbox( checked = checked, onCheckedChange = onCheckedChange, - size = if (large) CheckboxSize.LARGE else CheckboxSize.SMALL, + size = checkboxSize, enabled = enabled, + modifier = Modifier.semantics { contentDescription = title }, + ) + TaskRowText( + title = title, + checked = checked, + large = large, + description = description, + meta = meta, + progress = progress, + modifier = Modifier.weight(1f), ) - Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.textGap)) { - Text( - text = title, - style = (if (large) MaterialTheme.typography.bodyLarge else MaterialTheme.typography.bodyMedium) - .copy(textDecoration = done), - color = if (checked) colors.onSurfaceVariant else colors.onSurface, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - if (!description.isNullOrBlank()) { - Text( - text = description, - style = MaterialTheme.typography.bodySmall.copy(textDecoration = done), - color = colors.onSurfaceVariant, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - } - if (meta != null) { - Text(text = meta, style = EnclyTheme.typography.dataSmall, color = colors.onSurfaceVariant) - } - } if (priority != null) { Text( text = priority, @@ -157,6 +192,150 @@ fun EnclyTaskRow( color = if (priorityEmphasis && !checked) colors.error else colors.onSurfaceVariant, ) } + trailing?.invoke(this) + } +} + +/** The text column of [EnclyTaskRow]: title, description, meta line and segment bar. */ +@Suppress("LongParameterList") // EnclyTaskRow's text, passed through. +@Composable +private fun TaskRowText( + title: String, + checked: Boolean, + large: Boolean, + description: String?, + meta: String?, + progress: List?, + modifier: Modifier = Modifier, +) { + val colors = MaterialTheme.colorScheme + val done = if (checked) TextDecoration.LineThrough else TextDecoration.None + Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.textGap)) { + Text( + text = title, + style = (if (large) MaterialTheme.typography.bodyLarge else MaterialTheme.typography.bodyMedium) + .copy(textDecoration = done), + color = if (checked) colors.onSurfaceVariant else colors.onSurface, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + if (!description.isNullOrBlank()) { + Text( + text = description, + style = MaterialTheme.typography.bodySmall.copy(textDecoration = done), + color = colors.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + if (meta != null) { + Text(text = meta, style = EnclyTheme.typography.dataSmall, color = colors.onSurfaceVariant) + } + if (!progress.isNullOrEmpty()) { + EnclySegmentBar(done = progress, modifier = Modifier.padding(top = EnclyTheme.spacing.xxs)) + } + } +} + +/** + * The chevron button of a sub-task tree: an `onSurfaceVariant` arrow on a 48 dp target that + * points down, and turns 180° (animated) when [expanded]. + */ +@Composable +fun EnclyExpandButton( + expanded: Boolean, + contentDescription: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, +) { + val turn by animateFloatAsState( + targetValue = if (expanded) CHEVRON_TURN else 0f, + animationSpec = tween(TREE_ANIMATION_MS), + label = "chevron", + ) + val tint = MaterialTheme.colorScheme.onSurfaceVariant + IconButton(onClick = onClick, enabled = enabled, modifier = modifier) { + Icon( + EnclyIcons.ChevronDown, + contentDescription = contentDescription, + tint = if (enabled) tint else tint.disabled(), + modifier = Modifier + .size(EnclyTheme.spacing.iconSmall) + .rotate(turn), + ) + } +} + +/** A row's own action (edit, delete): a 20 dp [icon] in `onSurfaceVariant` on a 48 dp target. */ +@Composable +fun EnclyRowIconButton( + icon: ImageVector, + contentDescription: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, +) { + val tint = MaterialTheme.colorScheme.onSurfaceVariant + IconButton(onClick = onClick, enabled = enabled, modifier = modifier) { + Icon( + icon, + contentDescription = contentDescription, + tint = if (enabled) tint else tint.disabled(), + modifier = Modifier.size(EnclyTheme.spacing.iconSmall), + ) + } +} + +/** + * Sub-task progress on a task row: one rounded segment per sub-task, 3 dp apart, at most 160 dp + * wide. A done one is `primary` and 4 dp tall, an open one `outlineVariant` and 2 dp: the height + * tells them apart where the palette's primary is low in chroma. Past a dozen segments the gaps + * narrow so they still fit. Starts at the start edge (mirrored in RTL). Decorative: the row + * announces the count. + */ +@Composable +fun EnclySegmentBar(done: List, modifier: Modifier = Modifier) { + val colors = MaterialTheme.colorScheme + val rtl = LocalLayoutDirection.current == LayoutDirection.Rtl + Canvas( + modifier = modifier + .widthIn(max = SEGMENT_BAR_MAX_WIDTH) + .fillMaxWidth() + .height(SEGMENT_DONE_HEIGHT), + ) { + if (done.isEmpty()) return@Canvas + val count = done.size + val gap = (SEGMENT_GAP * minOf(1f, SEGMENT_FULL_GAP_COUNT.toFloat() / count)).toPx() + .coerceAtLeast(SEGMENT_MIN_GAP.toPx()) + val width = ((size.width - gap * (count - 1)) / count).coerceAtLeast(SEGMENT_MIN_GAP.toPx()) + done.forEachIndexed { index, isDone -> + val height = (if (isDone) SEGMENT_DONE_HEIGHT else SEGMENT_OPEN_HEIGHT).toPx() + val start = index * (width + gap) + val left = if (rtl) size.width - start - width else start + drawRoundRect( + color = if (isDone) colors.primary else colors.outlineVariant, + topLeft = Offset(left, (size.height - height) / 2), + size = Size(width, height), + cornerRadius = CornerRadius(height / 2), + ) + } + } +} + +/** + * The top of the sub-task tree on a task row: the same line as [subtaskConnector], from the + * bottom of the (vertically centred) checkbox down to the row's bottom edge. No semantics. + */ +@Composable +private fun Modifier.connectorBelowCheckbox(checkbox: CheckboxSize, color: Color): Modifier { + val spacing = EnclyTheme.spacing + val rtl = LocalLayoutDirection.current == LayoutDirection.Rtl + return drawBehind { + val trunk = spacing.minTouchTarget.toPx() / 2 + val x = if (rtl) size.width - trunk else trunk + val top = size.height / 2 + checkbox.size.toPx() / 2 + drawLine(color, Offset(x, top), Offset(x, size.height), spacing.hairline.toPx()) } } diff --git a/app/src/main/java/com/pasich/encly/presentation/dialogs/tasks/AddTaskDialog.kt b/app/src/main/java/com/pasich/encly/presentation/dialogs/tasks/AddTaskDialog.kt index 45a62fa..facc04d 100644 --- a/app/src/main/java/com/pasich/encly/presentation/dialogs/tasks/AddTaskDialog.kt +++ b/app/src/main/java/com/pasich/encly/presentation/dialogs/tasks/AddTaskDialog.kt @@ -186,8 +186,9 @@ private fun TaskEditorContent( keyboardActions = KeyboardActions(onDone = { actions.onSubmit() }), fieldModifier = Modifier.focusRequester(descriptionFocus), ) - PriorityChips(selected = state.priority, onSelect = actions.onPrioritySelect) + // Right under the description, so it stays in view above the keyboard. SubtaskEditor(checklist) + PriorityChips(selected = state.priority, onSelect = actions.onPrioritySelect) TaskEditorFooter(state, actions) } } diff --git a/app/src/main/java/com/pasich/encly/presentation/dialogs/tasks/SubtaskEditor.kt b/app/src/main/java/com/pasich/encly/presentation/dialogs/tasks/SubtaskEditor.kt index 393aeec..f2f8e44 100644 --- a/app/src/main/java/com/pasich/encly/presentation/dialogs/tasks/SubtaskEditor.kt +++ b/app/src/main/java/com/pasich/encly/presentation/dialogs/tasks/SubtaskEditor.kt @@ -45,6 +45,7 @@ import com.pasich.encly.ui.theme.EnclyTheme import sh.calvin.reorderable.ReorderableColumn import sh.calvin.reorderable.ReorderableScope +/** Longest sub-task title, in the sheet and in the list's inline field alike. */ internal const val SUBTASK_TITLE_MAX_LENGTH = 100 /** diff --git a/app/src/main/java/com/pasich/encly/presentation/screen/TasksList.kt b/app/src/main/java/com/pasich/encly/presentation/screen/TasksList.kt new file mode 100644 index 0000000..62a2955 --- /dev/null +++ b/app/src/main/java/com/pasich/encly/presentation/screen/TasksList.kt @@ -0,0 +1,237 @@ +package com.pasich.encly.presentation.screen + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.items +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.stringResource +import com.pasich.encly.R +import com.pasich.encly.data.model.Task +import com.pasich.encly.presentation.components.tasks.InlineSubtaskActions +import com.pasich.encly.presentation.components.tasks.SubtaskTreeActions +import com.pasich.encly.presentation.components.tasks.TaskFilterChips +import com.pasich.encly.presentation.components.tasks.TaskProgressCard +import com.pasich.encly.presentation.components.tiles.TaskItem +import com.pasich.encly.presentation.components.tiles.TaskItemActions +import com.pasich.encly.presentation.designsystem.EnclyEmptyState +import com.pasich.encly.presentation.designsystem.EnclyIcons +import com.pasich.encly.presentation.designsystem.EnclyInlineLink +import com.pasich.encly.presentation.designsystem.RowSkeleton +import com.pasich.encly.presentation.designsystem.SectionOverline +import com.pasich.encly.presentation.viewmodel.InlineSubtaskEdit +import com.pasich.encly.presentation.viewmodel.TaskFilter +import com.pasich.encly.presentation.viewmodel.TasksUiState +import com.pasich.encly.presentation.viewmodel.TasksViewModel +import com.pasich.encly.ui.theme.EnclyTheme + +private const val SKELETON_ROWS = 4 + +/** What the list shows: the tasks, which trees are open, and the inline sub-task field. */ +internal class TasksListState(val ui: TasksUiState, val expanded: Set, val inlineEdit: InlineSubtaskEdit?) + +/** The list's task and sub-task callbacks, all into [viewModel]. */ +internal fun taskItemActions(viewModel: TasksViewModel) = TaskItemActions( + onToggleTask = viewModel::toggleTaskCompletion, + onEdit = viewModel::showEditTaskDialog, + tree = SubtaskTreeActions( + onToggle = viewModel::toggleSubtask, + onStartAdding = viewModel::startAddingSubtask, + onStartRenaming = viewModel::startRenamingSubtask, + onMove = viewModel::moveSubtask, + onToggleTree = viewModel::toggleSubtasks, + inline = InlineSubtaskActions( + onTextChange = viewModel::onInlineTextChange, + onDone = viewModel::submitInlineEdit, + onClose = viewModel::closeInlineEdit, + onDelete = viewModel::deleteInlineSubtask, + ), + ), +) + +/** The list's own callbacks besides the tasks'. */ +internal class TasksListCallbacks( + val onFilter: (TaskFilter) -> Unit, + val onNewTask: () -> Unit, + val onClearCompleted: () -> Unit, +) + +/** + * The tasks list: kept above the keyboard (for an inline sub-task field), and a tap on nothing + * in particular closes (and saves) that field. + */ +@Composable +internal fun TasksList( + state: LazyListState, + list: TasksListState, + actions: TaskItemActions, + callbacks: TasksListCallbacks, + modifier: Modifier = Modifier, +) { + val focusManager = LocalFocusManager.current + LazyColumn( + state = state, + modifier = modifier + .fillMaxSize() + .imePadding() + .pointerInput(focusManager) { detectTapGestures { focusManager.clearFocus() } }, + verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.xxs), + contentPadding = tasksListPadding(), + ) { + tasksContent( + state = list, + actions = actions, + onFilter = callbacks.onFilter, + onNewTask = callbacks.onNewTask, + onClearCompleted = callbacks.onClearCompleted, + ) + } +} + +/** + * Back folds the tree opened last among the tasks the list shows ([onFold] gets their ids). + * An open inline field takes Back first, to close itself. + */ +@Composable +internal fun FoldTreesOnBack(list: TasksListState, onFold: (Set) -> Boolean) { + val visible = visibleTasks(list.ui).let { (open, done) -> (open + done).mapTo(HashSet()) { it.id } } + BackHandler(enabled = list.inlineEdit == null && list.expanded.any { it in visible }) { onFold(visible) } +} + +/** The gutter, and room below the last task for the FAB. */ +@Composable +private fun tasksListPadding() = PaddingValues( + start = EnclyTheme.spacing.gutter, + end = EnclyTheme.spacing.gutter, + top = EnclyTheme.spacing.xs, + bottom = EnclyTheme.spacing.fabHeight + EnclyTheme.spacing.l + EnclyTheme.spacing.m, +) + +/** The list: a skeleton while loading, else progress, chips, open tasks and the Done section. */ +private fun LazyListScope.tasksContent( + state: TasksListState, + actions: TaskItemActions, + onFilter: (TaskFilter) -> Unit, + onNewTask: () -> Unit, + onClearCompleted: () -> Unit, +) { + val ui = state.ui + if (ui.isLoading) { + items(SKELETON_ROWS) { RowSkeleton() } + return + } + if (ui.totalTasksCount == 0) { + item { + EnclyEmptyState( + icon = EnclyIcons.Checklist, + title = stringResource(R.string.task_empty_title), + body = stringResource(R.string.task_empty_desc), + actionLabel = stringResource(R.string.task_add), + onAction = onNewTask, + ) + } + return + } + progressAndFilters(ui, onFilter) + val showingCompleted = ui.selectedCompletedFilter != null + val (open, done) = visibleTasks(ui) + if (!showingCompleted && open.isEmpty()) { + item(key = "empty") { + EnclyEmptyState( + icon = EnclyIcons.Checklist, + title = stringResource(R.string.task_all_done_title).takeIf { ui.selectedPriorityFilter == null }, + body = stringResource( + if (ui.selectedPriorityFilter != + null + ) { + R.string.task_empty_filtered + } else { + R.string.task_empty_active + }, + ), + ) + } + } + taskRows(open, state, actions) + if (done.isNotEmpty()) { + item(key = "done") { + DoneHeader(count = done.size, onClear = onClearCompleted) + } + taskRows(done, state, actions) + } +} + +/** The open tasks and the Done section's tasks the list shows under the current filter. */ +private fun visibleTasks(ui: TasksUiState): Pair, List> { + val showingCompleted = ui.selectedCompletedFilter != null + val open = if (showingCompleted) emptyList() else ui.filteredActiveTasks + val done = when { + showingCompleted -> ui.completedTasks + + // A priority filter narrows the open tasks only; the Done section belongs to "All tasks". + ui.selectedPriorityFilter == null -> ui.completedTasks + + else -> emptyList() + } + return open to done +} + +/** One item per task: the task and, when open, its sub-task tree under it, so they move as one. */ +private fun LazyListScope.taskRows(tasks: List, state: TasksListState, actions: TaskItemActions) { + items(tasks, key = { it.id }) { task -> + TaskItem( + task = task, + subtasks = state.ui.subtasks[task.id].orEmpty(), + expanded = task.id in state.expanded, + actions = actions, + inlineEdit = state.inlineEdit?.takeIf { it.target.taskId == task.id }, + modifier = Modifier.animateItem(), + ) + } +} + +private fun LazyListScope.progressAndFilters(state: TasksUiState, onFilter: (TaskFilter) -> Unit) { + item(key = "progress") { + Column(verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.s)) { + TaskProgressCard( + completionPercentage = state.completionPercentage, + completedTasksCount = state.completedTasksCount, + totalTasksCount = state.totalTasksCount, + ) + TaskFilterChips( + availableFilters = state.availableFilters, + selectedFilterIds = setOfNotNull( + state.selectedActiveFilter?.id, + state.selectedPriorityFilter?.id, + state.selectedCompletedFilter?.id, + ), + onFilterSelect = onFilter, + ) + } + } +} + +/** "DONE · 3" and the Clear action for the completed tasks below it. */ +@Composable +private fun DoneHeader(count: Int, onClear: () -> Unit) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(top = EnclyTheme.spacing.m), + ) { + SectionOverline(stringResource(R.string.task_done_section, count), modifier = Modifier.weight(1f)) + EnclyInlineLink(text = stringResource(R.string.clear), onClick = onClear) + } +} diff --git a/app/src/main/java/com/pasich/encly/presentation/screen/TasksScreen.kt b/app/src/main/java/com/pasich/encly/presentation/screen/TasksScreen.kt index b3ba636..2d09039 100644 --- a/app/src/main/java/com/pasich/encly/presentation/screen/TasksScreen.kt +++ b/app/src/main/java/com/pasich/encly/presentation/screen/TasksScreen.kt @@ -2,16 +2,9 @@ package com.pasich.encly.presentation.screen import android.content.Context import androidx.annotation.StringRes -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme @@ -21,45 +14,37 @@ import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarResult import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.navigation.NavHostController import com.pasich.encly.R -import com.pasich.encly.data.model.Task -import com.pasich.encly.presentation.components.tasks.TaskFilterChips -import com.pasich.encly.presentation.components.tasks.TaskProgressCard -import com.pasich.encly.presentation.components.tiles.TaskItem -import com.pasich.encly.presentation.designsystem.EnclyEmptyState import com.pasich.encly.presentation.designsystem.EnclyFab import com.pasich.encly.presentation.designsystem.EnclyIcons -import com.pasich.encly.presentation.designsystem.EnclyInlineLink import com.pasich.encly.presentation.designsystem.EnclySnackbarHost import com.pasich.encly.presentation.designsystem.EnclyTopBar -import com.pasich.encly.presentation.designsystem.RowSkeleton -import com.pasich.encly.presentation.designsystem.SectionOverline import com.pasich.encly.presentation.dialogs.RequestCleanCompleteTaskDialog import com.pasich.encly.presentation.dialogs.tasks.AddTaskDialog -import com.pasich.encly.presentation.viewmodel.TaskFilter import com.pasich.encly.presentation.viewmodel.TaskOperationFailure import com.pasich.encly.presentation.viewmodel.TasksUiState import com.pasich.encly.presentation.viewmodel.TasksViewModel -import com.pasich.encly.ui.theme.EnclyTheme import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.launch -private const val SKELETON_ROWS = 4 - /** * The tasks screen: progress, filter chips, the open tasks and, below them, a "Done" section * with its "Clear" action. [openNewTask] opens the new-task sheet on arrival (the home card's @@ -81,38 +66,41 @@ fun TasksScreen( val context = LocalContext.current LaunchedEffect(viewModel) { showTaskMessages(viewModel, snackbarHostState, context) } val listState = rememberTasksListState(uiState) + val list = TasksListState(uiState, viewModel.expandedTaskIds.collectAsState().value, viewModel.inlineEdit) + val itemActions = remember(viewModel) { taskItemActions(viewModel) } + // Backgrounding re-locks the vault and drops this screen: save the inline field first. + OnPause(viewModel::flushInlineEditForBackground) + FoldTreesOnBack(list, viewModel::collapseLastExpanded) Scaffold( modifier = modifier, containerColor = MaterialTheme.colorScheme.surface, snackbarHost = { EnclySnackbarHost(snackbarHostState) }, topBar = { - EnclyTopBar(title = stringResource(R.string.main_drawer_tasks), onBack = navController::popBackStack) + EnclyTopBar( + title = stringResource(R.string.main_drawer_tasks), + onBack = { + viewModel.commitInlineEdit() + navController.popBackStack() + }, + ) }, floatingActionButton = { - EnclyFab( - text = stringResource(R.string.task_add), - icon = EnclyIcons.Plus, - expanded = listState.firstVisibleItemIndex == 0, - onClick = viewModel::showAddTaskDialog, - ) + // Out of the way of the keyboard and the inline field while one is open. + if (list.inlineEdit == null) TasksFab(listState, viewModel::showAddTaskDialog) }, ) { padding -> - LazyColumn( + TasksList( state = listState, - modifier = Modifier.fillMaxSize().padding(padding), - verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.xxs), - contentPadding = tasksListPadding(), - ) { - tasksContent( - state = uiState, - onToggle = viewModel::toggleTaskCompletion, - onOpen = viewModel::showEditTaskDialog, + list = list, + actions = itemActions, + callbacks = TasksListCallbacks( onFilter = viewModel::onFilterSelected, onNewTask = viewModel::showAddTaskDialog, onClearCompleted = { confirmClear = true }, - ) - } + ), + modifier = Modifier.padding(padding).consumeWindowInsets(padding), + ) if (viewModel.showAddTaskDialog.collectAsState().value) { AddTaskDialog( @@ -134,6 +122,27 @@ fun TasksScreen( } } +/** "New task", extended while the list is at its top. */ +@Composable +private fun TasksFab(listState: LazyListState, onClick: () -> Unit) { + val expanded by remember(listState) { derivedStateOf { listState.firstVisibleItemIndex == 0 } } + EnclyFab(text = stringResource(R.string.task_add), icon = EnclyIcons.Plus, expanded = expanded, onClick = onClick) +} + +/** Runs [action] whenever the screen's lifecycle pauses (the app leaves the foreground). */ +@Composable +private fun OnPause(action: () -> Unit) { + val currentAction by rememberUpdatedState(action) + val lifecycleOwner = LocalLifecycleOwner.current + DisposableEffect(lifecycleOwner) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_PAUSE) currentAction() + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } + } +} + /** Runs [open] once when [requested], and not again after a rotation. */ @Composable private fun OpenOnArrival(requested: Boolean, open: () -> Unit) { @@ -159,18 +168,9 @@ private fun rememberTasksListState(state: TasksUiState): LazyListState { return listState } -/** The gutter, and room below the last task for the FAB. */ -@Composable -private fun tasksListPadding() = PaddingValues( - start = EnclyTheme.spacing.gutter, - end = EnclyTheme.spacing.gutter, - top = EnclyTheme.spacing.xs, - bottom = EnclyTheme.spacing.fabHeight + EnclyTheme.spacing.l + EnclyTheme.spacing.m, -) - /** - * Failures, "Task deleted" with Undo, and "All sub-tasks done" with Complete task, as - * snackbars, until the calling effect ends. + * Failures, "Task deleted" and "Sub-task deleted" with Undo, and "All sub-tasks done" with + * Complete task, as snackbars, until the calling effect ends. */ private suspend fun showTaskMessages( viewModel: TasksViewModel, @@ -192,6 +192,16 @@ private suspend fun showTaskMessages( if (result == SnackbarResult.ActionPerformed) viewModel.restoreTask(task) } } + launch { + viewModel.subtaskDeletions.collect { subtask -> + val result = snackbarHostState.showSnackbar( + message = context.getString(R.string.subtask_deleted), + actionLabel = context.getString(R.string.undo), + duration = SnackbarDuration.Short, + ) + if (result == SnackbarResult.ActionPerformed) viewModel.restoreSubtask(subtask) + } + } launch { viewModel.completionOffers.collect { taskId -> val result = snackbarHostState.showSnackbar( @@ -204,120 +214,6 @@ private suspend fun showTaskMessages( } } -/** The list: a skeleton while loading, else progress, chips, open tasks and the Done section. */ -@Suppress("LongParameterList") // One lazy-list builder fed by the screen's callbacks. -private fun LazyListScope.tasksContent( - state: TasksUiState, - onToggle: (Long, Boolean) -> Unit, - onOpen: (Task) -> Unit, - onFilter: (TaskFilter) -> Unit, - onNewTask: () -> Unit, - onClearCompleted: () -> Unit, -) { - if (state.isLoading) { - items(SKELETON_ROWS) { RowSkeleton() } - return - } - if (state.totalTasksCount == 0) { - item { - EnclyEmptyState( - icon = EnclyIcons.Checklist, - title = stringResource(R.string.task_empty_title), - body = stringResource(R.string.task_empty_desc), - actionLabel = stringResource(R.string.task_add), - onAction = onNewTask, - ) - } - return - } - progressAndFilters(state, onFilter) - val showingCompleted = state.selectedCompletedFilter != null - val open = if (showingCompleted) emptyList() else state.filteredActiveTasks - val done = when { - showingCompleted -> state.completedTasks - - // A priority filter narrows the open tasks only; the Done section belongs to "All tasks". - state.selectedPriorityFilter == null -> state.completedTasks - - else -> emptyList() - } - if (!showingCompleted && open.isEmpty()) { - item(key = "empty") { - EnclyEmptyState( - icon = EnclyIcons.Checklist, - title = stringResource(R.string.task_all_done_title).takeIf { state.selectedPriorityFilter == null }, - body = stringResource( - if (state.selectedPriorityFilter != - null - ) { - R.string.task_empty_filtered - } else { - R.string.task_empty_active - }, - ), - ) - } - } - taskRows(open, state, onToggle, onOpen) - if (done.isNotEmpty()) { - item(key = "done") { - DoneHeader(count = done.size, onClear = onClearCompleted) - } - taskRows(done, state, onToggle, onOpen) - } -} - -/** One tile per task, with its sub-task progress when it has sub-tasks. */ -private fun LazyListScope.taskRows( - tasks: List, - state: TasksUiState, - onToggle: (Long, Boolean) -> Unit, - onOpen: (Task) -> Unit, -) { - items(tasks, key = { it.id }) { task -> - TaskItem( - task = task, - onTaskToggle = onToggle, - onTaskClick = onOpen, - subtasks = state.subtaskProgress[task.id], - modifier = Modifier.animateItem(), - ) - } -} - -private fun LazyListScope.progressAndFilters(state: TasksUiState, onFilter: (TaskFilter) -> Unit) { - item(key = "progress") { - Column(verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.s)) { - TaskProgressCard( - completionPercentage = state.completionPercentage, - completedTasksCount = state.completedTasksCount, - totalTasksCount = state.totalTasksCount, - ) - TaskFilterChips( - availableFilters = state.availableFilters, - selectedFilterIds = setOfNotNull( - state.selectedActiveFilter?.id, - state.selectedPriorityFilter?.id, - state.selectedCompletedFilter?.id, - ), - onFilterSelect = onFilter, - ) - } - } -} - -/** "DONE · 3" and the Clear action for the completed tasks below it. */ -@Composable -private fun DoneHeader(count: Int, onClear: () -> Unit) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(top = EnclyTheme.spacing.m), - ) { - SectionOverline(stringResource(R.string.task_done_section, count), modifier = Modifier.weight(1f)) - EnclyInlineLink(text = stringResource(R.string.clear), onClick = onClear) - } -} - /** What the user is told when a task operation fails. */ @StringRes private fun TaskOperationFailure.message(): Int = when (this) { diff --git a/app/src/main/java/com/pasich/encly/presentation/viewmodel/InlineSubtaskEdit.kt b/app/src/main/java/com/pasich/encly/presentation/viewmodel/InlineSubtaskEdit.kt new file mode 100644 index 0000000..480e9c3 --- /dev/null +++ b/app/src/main/java/com/pasich/encly/presentation/viewmodel/InlineSubtaskEdit.kt @@ -0,0 +1,21 @@ +package com.pasich.encly.presentation.viewmodel + +import com.pasich.encly.data.model.Subtask + +/** What the Tasks list's inline sub-task field is for: a new sub-task of a task, or renaming one. */ +sealed interface InlineSubtaskTarget { + val taskId: Long + + /** The "Add sub-task" field at the bottom of the task's tree. */ + data class Add(override val taskId: Long) : InlineSubtaskTarget + + /** A sub-task row turned into a field to rename it. */ + data class Rename(override val taskId: Long, val subtaskId: Long) : InlineSubtaskTarget +} + +/** + * The one inline sub-task field open on the Tasks list: its [target], the [text] typed so far + * and, when renaming, the stored sub-task it started from ([original], what Undo restores after a + * delete). + */ +data class InlineSubtaskEdit(val target: InlineSubtaskTarget, val text: String, val original: Subtask? = null) diff --git a/app/src/main/java/com/pasich/encly/presentation/viewmodel/TasksViewModel.kt b/app/src/main/java/com/pasich/encly/presentation/viewmodel/TasksViewModel.kt index 21cc329..4fc3210 100644 --- a/app/src/main/java/com/pasich/encly/presentation/viewmodel/TasksViewModel.kt +++ b/app/src/main/java/com/pasich/encly/presentation/viewmodel/TasksViewModel.kt @@ -1,15 +1,19 @@ package com.pasich.encly.presentation.viewmodel import androidx.annotation.StringRes +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.pasich.encly.core.security.NeverLocked import com.pasich.encly.core.security.VaultLockEvents import com.pasich.encly.data.model.Subtask -import com.pasich.encly.data.model.SubtaskProgress import com.pasich.encly.data.model.Task import com.pasich.encly.domain.repository.TasksRepository import com.pasich.encly.domain.usecase.task.UpdateTaskStatusUseCase +import com.pasich.encly.presentation.dialogs.tasks.SUBTASK_TITLE_MAX_LENGTH import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableSharedFlow @@ -47,6 +51,9 @@ enum class TaskOperationFailure { DELETE, } +/** SavedStateHandle key of the tasks whose sub-task tree is open. */ +private const val KEY_EXPANDED_TASKS = "expandedTaskIds" + /** How many open tasks the notes screen previews. */ const val HOME_WIDGET_TASKS = 2 @@ -80,16 +87,21 @@ data class TasksUiState( val selectedPriorityFilter: TaskFilter? = null, val selectedCompletedFilter: TaskFilter? = null, val filteredActiveTasks: List = emptyList(), - /** Sub-task progress by task id; a task without sub-tasks has no entry. */ - val subtaskProgress: Map = emptyMap(), + /** Sub-tasks by task id, in their order; a task without sub-tasks has no entry. */ + val subtasks: Map> = emptyMap(), val isLoading: Boolean = true, ) +/** + * The Tasks screen: the lists and filters, the editor sheet, and the list's own sub-task + * editing (each task's tree opens on a tap; one inline field adds or renames a sub-task). + */ @HiltViewModel class TasksViewModel @Inject constructor( private val tasksRepository: TasksRepository, private val updateTaskStatusUseCase: UpdateTaskStatusUseCase, lockEvents: VaultLockEvents = NeverLocked, + private val savedStateHandle: SavedStateHandle = SavedStateHandle(), ) : ViewModel() { private val _uiState = MutableStateFlow(TasksUiState()) @@ -119,6 +131,25 @@ class TasksViewModel @Inject constructor( private val _completionOffers = MutableSharedFlow(extraBufferCapacity = 1) val completionOffers: SharedFlow = _completionOffers.asSharedFlow() + /** + * Tasks whose sub-task tree is open on the list (all closed at first). Kept in the saved + * state, so a rotation keeps them open; ids of tasks that are gone are dropped. + */ + private val _expandedTaskIds = + MutableStateFlow(savedStateHandle.get(KEY_EXPANDED_TASKS)?.toSet().orEmpty()) + val expandedTaskIds: StateFlow> = _expandedTaskIds.asStateFlow() + + /** + * The one inline sub-task field open on the list, or null. Compose state rather than a flow, + * so the field reads back what was typed without a frame of delay. + */ + var inlineEdit: InlineSubtaskEdit? by mutableStateOf(null) + private set + + /** A sub-task that was just deleted from the list, so the screen can offer to undo it. */ + private val _subtaskDeletions = MutableSharedFlow(extraBufferCapacity = 1) + val subtaskDeletions: SharedFlow = _subtaskDeletions.asSharedFlow() + /** Sub-tasks of deleted tasks by task id, for [restoreTask]; the delete cascades to them. */ private val deletedSubtasks = mutableMapOf>() @@ -127,6 +158,13 @@ class TasksViewModel @Inject constructor( /** Serializes background draft saves so two quick pauses cannot insert twice. */ private val draftMutex = Mutex() + /** + * Serializes the list's sub-task writes (ticks, inline adds, renames, deletes) and the + * sheet's checklist load after them: two quick ticks cannot both offer to complete the task, + * and the sheet shows what the list just saved. + */ + private val subtaskMutex = Mutex() + private var tasksJob: Job? = null init { @@ -139,6 +177,7 @@ class TasksViewModel @Inject constructor( _editingSubtasks.value = emptyList() deletedSubtasks.clear() _showAddTaskDialog.value = false + inlineEdit = null } } @@ -149,17 +188,200 @@ class TasksViewModel @Inject constructor( tasksRepository.getAllCompletedTasks(), tasksRepository.getActiveTasksCount(), tasksRepository.getCompletedTasksCount(), - tasksRepository.getSubtaskProgress(), - ) { activeTasks, completedTasks, activeCount, completedCount, progress -> + tasksRepository.observeSubtasks(), + ) { activeTasks, completedTasks, activeCount, completedCount, subtasks -> TaskFilterEngine.reduce(_uiState.value, activeTasks, completedTasks, activeCount, completedCount) - .copy(subtaskProgress = progress.associateBy { it.taskId }) + .copy(subtasks = subtasks.groupBy { it.taskId }) }.collect { newState -> _uiState.value = newState + dropGoneListState(newState) + } + } + } + + /** Forgets open trees and the inline field of tasks (or a renamed sub-task) that are gone. */ + private fun dropGoneListState(state: TasksUiState) { + val ids = (state.activeTasks + state.completedTasks).mapTo(HashSet()) { it.id } + val expanded = _expandedTaskIds.value + if (!ids.containsAll(expanded)) setExpanded(expanded.filterTo(LinkedHashSet()) { it in ids }) + val target = inlineEdit?.target ?: return + val renamedGone = target is InlineSubtaskTarget.Rename && + state.subtasks[target.taskId].orEmpty().none { it.id == target.subtaskId } + val gone = target.taskId !in ids || renamedGone + if (gone) inlineEdit = null + } + + private fun setExpanded(ids: Set) { + _expandedTaskIds.value = ids + savedStateHandle[KEY_EXPANDED_TASKS] = ids.toLongArray() + } + + /** Opens or closes the task's sub-task tree on the list. The set keeps the order they opened in. */ + fun toggleSubtasks(taskId: Long) { + commitInlineEdit() + val expanded = _expandedTaskIds.value + setExpanded(if (taskId in expanded) expanded - taskId else expanded + taskId) + } + + /** + * Back on the list: folds the tree opened last among [visible] (the tasks the list shows + * now). False when none of them is open, so Back can leave the screen. + */ + fun collapseLastExpanded(visible: Set): Boolean { + val last = _expandedTaskIds.value.lastOrNull { it in visible } ?: return false + commitInlineEdit() + setExpanded(_expandedTaskIds.value - last) + return true + } + + /** Drag or "Move up/down" in a task's open tree: stores the new order, keeping each row's identity. */ + fun moveSubtask(taskId: Long, from: Int, to: Int) { + commitInlineEdit() + launchSubtaskWrite { + val rows = tasksRepository.getSubtasks(taskId).getOrNull() ?: return@launchSubtaskWrite + if (from !in rows.indices || to !in rows.indices || from == to) return@launchSubtaskWrite + val moved = rows.toMutableList().apply { add(to, removeAt(from)) } + if (tasksRepository.saveSubtasks( + taskId, + moved, + ).isFailure + ) { + _operationFailures.emit(TaskOperationFailure.UPDATE) + } + } + } + + /** Turns the task's "Add sub-task" button into the inline field (closing any other one). */ + fun startAddingSubtask(taskId: Long) { + val target = InlineSubtaskTarget.Add(taskId) + if (inlineEdit?.target == target) return + commitInlineEdit() + setExpanded(_expandedTaskIds.value + taskId) + inlineEdit = InlineSubtaskEdit(target, text = "") + } + + /** Turns the sub-task's row into the inline field with its title (closing any other one). */ + fun startRenamingSubtask(subtask: Subtask) { + val target = InlineSubtaskTarget.Rename(subtask.taskId, subtask.id) + if (inlineEdit?.target == target) return + commitInlineEdit() + inlineEdit = InlineSubtaskEdit(target, text = subtask.title, original = subtask) + } + + fun onInlineTextChange(text: String) { + inlineEdit = inlineEdit?.copy(text = text.take(SUBTASK_TITLE_MAX_LENGTH)) + } + + /** + * The keyboard's Done. Adding: a title is saved as the task's last sub-task and the field + * stays open, empty, for the next one; a blank field closes. Renaming: like [closeInlineEdit]. + */ + fun submitInlineEdit() { + val edit = inlineEdit ?: return + if (edit.target is InlineSubtaskTarget.Add && edit.text.isNotBlank()) { + inlineEdit = edit.copy(text = "") + writeInlineEdit(edit) + } else { + closeInlineEdit(edit.target) + } + } + + /** + * Closes the field for [target] (it lost focus, the user tapped elsewhere or pressed Back) + * and saves it: a new title is added, a rename is stored, and a rename left blank deletes the + * sub-task (with Undo). A blank new sub-task is simply dropped. Ignored when the field open + * now is another one. + */ + fun closeInlineEdit(target: InlineSubtaskTarget) { + val edit = inlineEdit?.takeIf { it.target == target } ?: return + inlineEdit = null + writeInlineEdit(edit) + } + + /** The ✕ of a sub-task being renamed: deletes it, with Undo. */ + fun deleteInlineSubtask() { + val original = inlineEdit?.original ?: return + inlineEdit = null + launchSubtaskWrite { deleteSubtaskWithUndo(original) } + } + + /** Undo for a sub-task deleted from the list: back at its position, with its uid. */ + fun restoreSubtask(subtask: Subtask) { + launchSubtaskWrite { + if (tasksRepository.restoreSubtask(subtask).isFailure) _operationFailures.emit(TaskOperationFailure.UPDATE) + } + } + + /** + * Saves what the inline field holds when the app leaves the foreground (the re-lock drops + * the screen), like the sheet's [saveDraftForBackground]. A blank field saves nothing. The + * field stays open: an added title is cleared from it so it is not added twice. + */ + fun flushInlineEditForBackground() { + val edit = inlineEdit ?: return + if (edit.text.isBlank()) return + inlineEdit = when (edit.target) { + is InlineSubtaskTarget.Add -> edit.copy(text = "") + is InlineSubtaskTarget.Rename -> edit.copy(original = edit.original?.copy(title = edit.text.trim())) + } + writeInlineEdit(edit) + } + + /** + * Saves and closes the open inline field, like losing focus does: before any other + * interaction with the list, and when the screen is left. + */ + fun commitInlineEdit() { + inlineEdit?.let { closeInlineEdit(it.target) } + } + + private fun writeInlineEdit(edit: InlineSubtaskEdit) { + val title = edit.text.trim() + val original = edit.original + launchSubtaskWrite { + val failed = when { + original == null -> title.isNotEmpty() && + tasksRepository.addSubtask(edit.target.taskId, title).isFailure + + title.isEmpty() -> !deleteSubtaskWithUndo(original) + + title != original.title -> tasksRepository.renameSubtask(original.id, title).isFailure + + else -> false } + if (failed) _operationFailures.emit(TaskOperationFailure.UPDATE) + } + } + + /** + * Deletes [subtask] and offers Undo; false when the delete failed. Deleting the last open + * sub-task of an open task leaves only done ones, so it offers to complete the task too, + * like ticking it would. + */ + private suspend fun deleteSubtaskWithUndo(subtask: Subtask): Boolean { + val deleted = tasksRepository.deleteSubtask(subtask.id).isSuccess + if (deleted) { + _subtaskDeletions.emit(subtask) + offerCompletionAfterDelete(subtask) + } + return deleted + } + + private suspend fun offerCompletionAfterDelete(deleted: Subtask) { + val after = tasksRepository.getSubtasks(deleted.taskId).getOrNull() ?: return + val state = uiState.value + val task = (state.activeTasks + state.completedTasks).find { it.id == deleted.taskId } + if (task != null && SubtaskDrafts.offersCompletion(task.isCompleted, after + deleted, after)) { + _completionOffers.emit(task.id) } } + private fun launchSubtaskWrite(write: suspend () -> Unit) { + viewModelScope.launch { subtaskMutex.withLock { write() } } + } + fun showAddTaskDialog() { + commitInlineEdit() subtasksJob?.cancel() _editingTask.value = null _editingSubtasks.value = emptyList() @@ -167,6 +389,7 @@ class TasksViewModel @Inject constructor( } fun showEditTaskDialog(task: Task) { + commitInlineEdit() subtasksJob?.cancel() _editingTask.value = task // Unknown until loaded: a sheet saved before then leaves the stored checklist alone, @@ -174,7 +397,9 @@ class TasksViewModel @Inject constructor( _editingSubtasks.value = null _showAddTaskDialog.value = true subtasksJob = viewModelScope.launch { - tasksRepository.getSubtasks(task.id).onSuccess { _editingSubtasks.value = SubtaskDrafts.fromSubtasks(it) } + // After any list write still in flight, so the sheet shows it. + subtaskMutex.withLock { tasksRepository.getSubtasks(task.id) } + .onSuccess { _editingSubtasks.value = SubtaskDrafts.fromSubtasks(it) } } } @@ -313,6 +538,7 @@ class TasksViewModel @Inject constructor( } fun toggleTaskCompletion(taskId: Long, isCompleted: Boolean) { + commitInlineEdit() viewModelScope.launch { if (updateTaskStatusUseCase(taskId, isCompleted).isFailure) { _operationFailures.emit(TaskOperationFailure.STATUS_UPDATE) @@ -320,6 +546,29 @@ class TasksViewModel @Inject constructor( } } + /** + * Ticks or unticks a sub-task from the list, saved at once. When that ticks the last open + * sub-task of an open task, offers to complete it, like a sheet save does. + */ + fun toggleSubtask(subtask: Subtask, done: Boolean) { + commitInlineEdit() + viewModelScope.launch { + subtaskMutex.withLock { + if (tasksRepository.setSubtaskCompleted(subtask.id, done).isFailure) { + _operationFailures.emit(TaskOperationFailure.STATUS_UPDATE) + return@withLock + } + val after = tasksRepository.getSubtasks(subtask.taskId).getOrNull() ?: return@withLock + val before = after.map { if (it.id == subtask.id) it.copy(isCompleted = !done) else it } + val state = uiState.value + val task = (state.activeTasks + state.completedTasks).find { it.id == subtask.taskId } + if (task != null && SubtaskDrafts.offersCompletion(task.isCompleted, before, after)) { + _completionOffers.emit(task.id) + } + } + } + } + fun deleteTask(task: Task) { viewModelScope.launch { // Read before the delete cascades to them, so Undo can bring them back. @@ -357,6 +606,7 @@ class TasksViewModel @Inject constructor( } fun onFilterSelected(filter: TaskFilter) { + commitInlineEdit() _uiState.value = TaskFilterEngine.select(_uiState.value, filter) } } diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 80fcf9e..d7a6828 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -559,4 +559,15 @@ Unteraufgabe löschen Alle Unteraufgaben sind erledigt Aufgabe erledigen + Unteraufgaben anzeigen + Unteraufgaben ausblenden + Ausgeklappt + Eingeklappt + Unteraufgabe gelöscht + Nächster Schritt: %1$s + Erster Schritt + + %1$d von %2$d Unteraufgabe erledigt + %1$d von %2$d Unteraufgaben erledigt + diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 7bf8a80..a7e69fc 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -564,4 +564,16 @@ Eliminar subtarea Todas las subtareas están hechas Completar tarea + Mostrar subtareas + Ocultar subtareas + Expandido + Contraído + Subtarea eliminada + Siguiente paso: %1$s + Primer paso + + %1$d de %2$d subtarea completada + %1$d de %2$d subtareas completadas + %1$d de %2$d subtareas completadas + diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 04276f7..ac7e5e5 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -564,4 +564,16 @@ Supprimer la sous-tâche Toutes les sous-tâches sont terminées Terminer la tâche + Afficher les sous-tâches + Masquer les sous-tâches + Développé + Réduit + Sous-tâche supprimée + Prochaine étape : %1$s + Première étape + + %1$d sur %2$d sous-tâche terminée + %1$d sur %2$d sous-tâches terminées + %1$d sur %2$d sous-tâches terminées + diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 7440680..900166c 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -564,4 +564,16 @@ Elimina sottoattività Tutte le sottoattività sono completate Completa l\'attività + Mostra sottoattività + Nascondi sottoattività + Espanso + Compresso + Sottoattività eliminata + Prossimo passo: %1$s + Primo passo + + %1$d di %2$d sottoattività completata + %1$d di %2$d sottoattività completate + %1$d di %2$d sottoattività completate + diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 55e0c1a..55fc7e3 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -559,4 +559,15 @@ Subtaak verwijderen Alle subtaken zijn klaar Taak voltooien + Subtaken tonen + Subtaken verbergen + Uitgevouwen + Samengevouwen + Subtaak verwijderd + Volgende stap: %1$s + Eerste stap + + %1$d van %2$d subtaak voltooid + %1$d van %2$d subtaken voltooid + diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 47ec49c..329ea29 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -569,4 +569,17 @@ Usuń podzadanie Wszystkie podzadania wykonane Wykonaj zadanie + Pokaż podzadania + Ukryj podzadania + Rozwinięte + Zwinięte + Podzadanie usunięte + Następny krok: %1$s + Pierwszy krok + + Wykonano %1$d z %2$d podzadania + Wykonano %1$d z %2$d podzadań + Wykonano %1$d z %2$d podzadań + Wykonano %1$d z %2$d podzadania + diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 2ddf104..44453b8 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -564,4 +564,16 @@ Excluir subtarefa Todas as subtarefas estão concluídas Concluir tarefa + Mostrar subtarefas + Ocultar subtarefas + Expandido + Recolhido + Subtarefa excluída + Próximo passo: %1$s + Primeiro passo + + %1$d de %2$d subtarefa concluída + %1$d de %2$d subtarefas concluídas + %1$d de %2$d subtarefas concluídas + diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 1c72c96..700c428 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -569,4 +569,17 @@ Видалити підзавдання Усі підзавдання виконано Завершити завдання + Показати підзавдання + Сховати підзавдання + Розгорнуто + Згорнуто + Підзавдання видалено + Наступний крок: %1$s + Перший крок + + %1$d з %2$d підзавдання виконано + %1$d з %2$d підзавдань виконано + %1$d з %2$d підзавдань виконано + %1$d з %2$d підзавдання виконано + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 97015be..09e3a3d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -576,5 +576,15 @@ Delete sub-task All sub-tasks are done Complete task - %1$d/%2$d + Show sub-tasks + Hide sub-tasks + Expanded + Collapsed + Sub-task deleted + Next step: %1$s + First step + + %1$d of %2$d sub-task done + %1$d of %2$d sub-tasks done + diff --git a/app/src/test/java/com/pasich/encly/data/database/SubtasksDaoTest.kt b/app/src/test/java/com/pasich/encly/data/database/SubtasksDaoTest.kt index 60bcfb1..42361c7 100644 --- a/app/src/test/java/com/pasich/encly/data/database/SubtasksDaoTest.kt +++ b/app/src/test/java/com/pasich/encly/data/database/SubtasksDaoTest.kt @@ -4,7 +4,6 @@ import android.app.Application import androidx.room.Room import androidx.test.core.app.ApplicationProvider import com.pasich.encly.data.model.Subtask -import com.pasich.encly.data.model.SubtaskProgress import com.pasich.encly.data.model.Task import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking @@ -103,19 +102,85 @@ class SubtasksDaoTest { } @Test - fun progressCountsDoneAndTotalPerTask() = runBlocking { - val task = dao.insertTask(Task(title = "Trip")) + fun observeSubtasksListsEveryTaskInItsOrder() = runBlocking { + val trip = dao.insertTask(Task(title = "Trip")) + val shop = dao.insertTask(Task(title = "Shop")) dao.insertTask(Task(title = "No checklist")) + dao.replaceSubtasks(shop, listOf(Subtask(taskId = shop, title = "Milk"))) dao.replaceSubtasks( - task, + trip, listOf( - Subtask(taskId = task, title = "a", isCompleted = true), - Subtask(taskId = task, title = "b"), - Subtask(taskId = task, title = "c", isCompleted = true), + Subtask(taskId = trip, title = "a", isCompleted = true), + Subtask(taskId = trip, title = "b"), ), ) - assertEquals(listOf(SubtaskProgress(task, done = 2, total = 3)), dao.getSubtaskProgress().first()) + val rows = dao.observeSubtasks().first() + + assertEquals(listOf(trip to "a", trip to "b", shop to "Milk"), rows.map { it.taskId to it.title }) + assertEquals(listOf(0, 1, 0), rows.map { it.position }) + } + + @Test + fun setSubtaskCompletedTicksAndUnticksOneRow() = runBlocking { + val task = dao.insertTask(Task(title = "Trip")) + dao.replaceSubtasks(task, listOf(Subtask(taskId = task, title = "a"), Subtask(taskId = task, title = "b"))) + val (a, b) = dao.getSubtasks(task) + + assertEquals(1, dao.setSubtaskCompleted(a.id, done = true)) + assertEquals(listOf(true, false), dao.observeSubtasks().first().map { it.isCompleted }) + + dao.setSubtaskCompleted(a.id, done = false) + assertEquals(listOf(false, false), dao.getSubtasks(task).map { it.isCompleted }) + assertEquals(b.uid, dao.getSubtasks(task)[1].uid) + assertEquals(0, dao.setSubtaskCompleted(id = 999, done = true)) + } + + @Test + fun appendSubtaskAddsAfterTheLastOne() = runBlocking { + val task = dao.insertTask(Task(title = "Trip")) + val first = dao.appendSubtask(task, "Tickets") + dao.replaceSubtasks(task, dao.getSubtasks(task) + Subtask(taskId = task, title = "Hotel")) + + val last = dao.appendSubtask(task, "Bags") + + val rows = dao.getSubtasks(task) + assertEquals(listOf("Tickets", "Hotel", "Bags"), rows.map { it.title }) + assertEquals(listOf(0, 1, 2), rows.map { it.position }) + assertEquals(listOf(first, last), listOf(rows.first().id, rows.last().id)) + assertEquals(32, rows.last().uid.length) + } + + @Test + fun renameAndDeleteTouchOneRow() = runBlocking { + val task = dao.insertTask(Task(title = "Trip")) + listOf("a", "b", "c").forEach { dao.appendSubtask(task, it) } + val (a, b, c) = dao.getSubtasks(task) + + assertEquals(1, dao.renameSubtask(b.id, "B")) + assertEquals(1, dao.deleteSubtaskById(a.id)) + + assertEquals(listOf(b.copy(title = "B"), c), dao.getSubtasks(task)) + assertEquals(0, dao.renameSubtask(a.id, "gone")) + assertEquals(0, dao.deleteSubtaskById(a.id)) + } + + @Test + fun restoreSubtaskPutsItBackAtItsPositionWithItsUid() = runBlocking { + val task = dao.insertTask(Task(title = "Trip")) + listOf("a", "b", "c").forEach { dao.appendSubtask(task, it) } + val b = dao.getSubtasks(task)[1].copy(isCompleted = true) + dao.setSubtaskCompleted(b.id, done = true) + dao.deleteSubtaskById(b.id) + // Positions were compacted meanwhile (the sheet saved): b's slot is taken by c. + dao.replaceSubtasks(task, dao.getSubtasks(task)) + + dao.restoreSubtask(b) + + val rows = dao.getSubtasks(task) + assertEquals(listOf("a", "b", "c"), rows.map { it.title }) + assertEquals(b.uid, rows[1].uid) + assertTrue(rows[1].isCompleted) } @Test diff --git a/app/src/test/java/com/pasich/encly/presentation/viewmodel/TasksViewModelDraftTest.kt b/app/src/test/java/com/pasich/encly/presentation/viewmodel/TasksViewModelDraftTest.kt index 41123f4..802867c 100644 --- a/app/src/test/java/com/pasich/encly/presentation/viewmodel/TasksViewModelDraftTest.kt +++ b/app/src/test/java/com/pasich/encly/presentation/viewmodel/TasksViewModelDraftTest.kt @@ -1,7 +1,6 @@ package com.pasich.encly.presentation.viewmodel import com.pasich.encly.data.model.Subtask -import com.pasich.encly.data.model.SubtaskProgress import com.pasich.encly.data.model.Task import com.pasich.encly.domain.repository.TasksRepository import com.pasich.encly.domain.usecase.task.UpdateTaskStatusUseCase @@ -200,7 +199,17 @@ private class InMemoryTasksRepository : TasksRepository { override suspend fun deleteAllCompletedTasks(): Result = Result.success(Unit) - override fun getSubtaskProgress(): Flow> = flowOf(emptyList()) + override fun observeSubtasks(): Flow> = flowOf(emptyList()) + + override suspend fun setSubtaskCompleted(id: Long, done: Boolean): Result = Result.success(Unit) + + override suspend fun addSubtask(taskId: Long, title: String): Result = Result.success(0) + + override suspend fun renameSubtask(id: Long, title: String): Result = Result.success(Unit) + + override suspend fun deleteSubtask(id: Long): Result = Result.success(Unit) + + override suspend fun restoreSubtask(subtask: Subtask): Result = Result.success(Unit) override suspend fun getSubtasks(taskId: Long): Result> = Result.success(subtasks.filter { it.taskId == taskId }) diff --git a/app/src/test/java/com/pasich/encly/presentation/viewmodel/TasksViewModelInlineSubtasksTest.kt b/app/src/test/java/com/pasich/encly/presentation/viewmodel/TasksViewModelInlineSubtasksTest.kt new file mode 100644 index 0000000..c24c911 --- /dev/null +++ b/app/src/test/java/com/pasich/encly/presentation/viewmodel/TasksViewModelInlineSubtasksTest.kt @@ -0,0 +1,406 @@ +package com.pasich.encly.presentation.viewmodel + +import androidx.lifecycle.SavedStateHandle +import com.pasich.encly.data.model.Subtask +import com.pasich.encly.data.model.Task +import com.pasich.encly.domain.usecase.task.UpdateTaskStatusUseCase +import com.pasich.encly.presentation.dialogs.tasks.SUBTASK_TITLE_MAX_LENGTH +import com.pasich.encly.testutil.TestTasksRepository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +/** + * Sub-tasks edited on the Tasks list itself: open/closed trees, the inline "Add sub-task" field, + * renaming and deleting a sub-task in place (with Undo), and saving on backgrounding. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class TasksViewModelInlineSubtasksTest { + private val dispatcher = StandardTestDispatcher() + private lateinit var repository: TestTasksRepository + private lateinit var savedState: SavedStateHandle + private lateinit var viewModel: TasksViewModel + + private val shop get() = repository.subtasks.value.filter { it.taskId == 1L }.sortedBy { it.position } + + @Before + fun setUp() { + Dispatchers.setMain(dispatcher) + repository = TestTasksRepository( + initial = listOf(Task(id = 1, title = "Shop"), Task(id = 2, title = "Call")), + initialSubtasks = listOf( + Subtask(id = 10, taskId = 1, title = "Milk", isCompleted = true, position = 0, uid = "u10"), + Subtask(id = 11, taskId = 1, title = "Eggs", position = 1, uid = "u11"), + Subtask(id = 12, taskId = 1, title = "Bread", position = 2, uid = "u12"), + ), + ) + savedState = SavedStateHandle() + viewModel = newViewModel() + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + private fun newViewModel() = + TasksViewModel(repository, UpdateTaskStatusUseCase(repository), savedStateHandle = savedState) + + @Test + fun treesAreClosedAtFirstAndATapTogglesOne() = runTest(dispatcher) { + advanceUntilIdle() + assertEquals(emptySet(), viewModel.expandedTaskIds.value) + + viewModel.toggleSubtasks(1) + viewModel.toggleSubtasks(2) + assertEquals(setOf(1L, 2L), viewModel.expandedTaskIds.value) + + viewModel.toggleSubtasks(1) + assertEquals(setOf(2L), viewModel.expandedTaskIds.value) + } + + @Test + fun openTreesSurviveARecreatedViewModelAndListUpdates() = runTest(dispatcher) { + advanceUntilIdle() + viewModel.toggleSubtasks(1) + + val recreated = newViewModel() + advanceUntilIdle() + assertEquals(setOf(1L), recreated.expandedTaskIds.value) + + repository.tasks.value += Task(id = 3, title = "New") + repository.subtasks.value += Subtask(id = 20, taskId = 3, title = "x", uid = "u20") + advanceUntilIdle() + assertEquals(setOf(1L), recreated.expandedTaskIds.value) + } + + @Test + fun aTaskThatIsGoneIsDroppedFromTheOpenTrees() = runTest(dispatcher) { + advanceUntilIdle() + viewModel.toggleSubtasks(1) + viewModel.toggleSubtasks(2) + + viewModel.deleteTask(repository.tasks.value.single { it.id == 2L }) + advanceUntilIdle() + + assertEquals(setOf(1L), viewModel.expandedTaskIds.value) + assertEquals(longArrayOf(1L).toList(), savedState.get("expandedTaskIds")?.toList()) + // Ids restored from the saved state of tasks that no longer exist are dropped too. + savedState["expandedTaskIds"] = longArrayOf(1L, 99L) + val recreated = newViewModel() + advanceUntilIdle() + assertEquals(setOf(1L), recreated.expandedTaskIds.value) + } + + @Test + fun backFoldsTheTreeOpenedLastAmongTheVisibleOnes() = runTest(dispatcher) { + repository.tasks.value += Task(id = 3, title = "Read") + advanceUntilIdle() + viewModel.toggleSubtasks(2) + viewModel.toggleSubtasks(1) + viewModel.toggleSubtasks(3) + + assertTrue(viewModel.collapseLastExpanded(visible = setOf(1L, 2L))) + assertEquals(setOf(2L, 3L), viewModel.expandedTaskIds.value) + assertTrue(viewModel.collapseLastExpanded(visible = setOf(1L, 2L))) + assertEquals(setOf(3L), viewModel.expandedTaskIds.value) + assertFalse(viewModel.collapseLastExpanded(visible = setOf(1L, 2L))) + // The order survives the saved state. + viewModel.toggleSubtasks(1) + assertEquals(listOf(3L, 1L), newViewModel().expandedTaskIds.value.toList()) + } + + @Test + fun movingASubtaskStoresTheNewOrderKeepingItsIdentity() = runTest(dispatcher) { + advanceUntilIdle() + + viewModel.moveSubtask(1, from = 2, to = 0) + advanceUntilIdle() + + assertEquals(listOf("Bread", "Milk", "Eggs"), shop.map { it.title }) + assertEquals(listOf("u12", "u10", "u11"), shop.map { it.uid }) + viewModel.moveSubtask(1, from = 0, to = 5) + advanceUntilIdle() + assertEquals(listOf("Bread", "Milk", "Eggs"), shop.map { it.title }) + } + + @Test + fun doneAddsTheSubtaskLastAndKeepsTheFieldOpenForTheNext() = runTest(dispatcher) { + advanceUntilIdle() + viewModel.startAddingSubtask(1) + assertEquals(InlineSubtaskTarget.Add(1), viewModel.inlineEdit?.target) + assertTrue(1L in viewModel.expandedTaskIds.value) + + viewModel.onInlineTextChange(" Butter ") + viewModel.submitInlineEdit() + assertEquals("", viewModel.inlineEdit?.text) + viewModel.onInlineTextChange("Jam") + viewModel.submitInlineEdit() + advanceUntilIdle() + + assertEquals(listOf("Milk", "Eggs", "Bread", "Butter", "Jam"), shop.map { it.title }) + assertEquals(listOf(0, 1, 2, 3, 4), shop.map { it.position }) + assertEquals(InlineSubtaskEdit(InlineSubtaskTarget.Add(1), ""), viewModel.inlineEdit) + assertEquals("Jam", viewModel.uiState.value.subtasks.getValue(1L).last().title) + } + + @Test + fun doneOnABlankFieldClosesItWithoutAdding() = runTest(dispatcher) { + advanceUntilIdle() + viewModel.startAddingSubtask(2) + viewModel.onInlineTextChange(" ") + + viewModel.submitInlineEdit() + advanceUntilIdle() + + assertNull(viewModel.inlineEdit) + assertTrue(repository.subtasks.value.none { it.taskId == 2L }) + } + + @Test + fun losingFocusSavesAFilledFieldAndDropsABlankOne() = runTest(dispatcher) { + advanceUntilIdle() + viewModel.startAddingSubtask(2) + viewModel.onInlineTextChange("Mom") + viewModel.closeInlineEdit(InlineSubtaskTarget.Add(2)) + viewModel.startAddingSubtask(2) + viewModel.closeInlineEdit(InlineSubtaskTarget.Add(2)) + advanceUntilIdle() + + assertNull(viewModel.inlineEdit) + assertEquals(listOf("Mom"), repository.subtasks.value.filter { it.taskId == 2L }.map { it.title }) + } + + @Test + fun theTitleIsCutToTheSheetsLimit() = runTest(dispatcher) { + viewModel.startAddingSubtask(2) + viewModel.onInlineTextChange("x".repeat(SUBTASK_TITLE_MAX_LENGTH + 20)) + + assertEquals(SUBTASK_TITLE_MAX_LENGTH, viewModel.inlineEdit?.text?.length) + } + + @Test + fun onlyOneFieldIsOpenAndOpeningAnotherSavesTheFirst() = runTest(dispatcher) { + advanceUntilIdle() + viewModel.startAddingSubtask(1) + viewModel.onInlineTextChange("Butter") + + viewModel.startAddingSubtask(2) + // The first field's late focus loss must not close the second one. + viewModel.closeInlineEdit(InlineSubtaskTarget.Add(1)) + advanceUntilIdle() + + assertEquals(InlineSubtaskTarget.Add(2), viewModel.inlineEdit?.target) + assertEquals("Butter", shop.last().title) + + viewModel.onInlineTextChange("Mom") + viewModel.startRenamingSubtask(shop.first()) + advanceUntilIdle() + assertEquals(InlineSubtaskTarget.Rename(1, 10), viewModel.inlineEdit?.target) + assertEquals("Milk", viewModel.inlineEdit?.text) + assertEquals(listOf("Mom"), repository.subtasks.value.filter { it.taskId == 2L }.map { it.title }) + } + + @Test + fun otherTapsOnTheListSaveTheFieldFirst() = runTest(dispatcher) { + advanceUntilIdle() + viewModel.startAddingSubtask(1) + viewModel.onInlineTextChange("Butter") + + viewModel.toggleSubtasks(2) + advanceUntilIdle() + + assertNull(viewModel.inlineEdit) + assertEquals("Butter", shop.last().title) + } + + @Test + fun renamingSavesTheNewTitleInPlace() = runTest(dispatcher) { + advanceUntilIdle() + val eggs = shop[1] + viewModel.startRenamingSubtask(eggs) + viewModel.onInlineTextChange(" Brown eggs ") + + viewModel.submitInlineEdit() + advanceUntilIdle() + + assertNull(viewModel.inlineEdit) + assertEquals(eggs.copy(title = "Brown eggs"), shop[1]) + } + + @Test + fun aRenameLeftBlankDeletesTheSubtaskAndUndoPutsItBack() = runTest(dispatcher) { + val deletions = collect { viewModel.subtaskDeletions.collect(it::add) } + advanceUntilIdle() + val eggs = shop[1] + viewModel.startRenamingSubtask(eggs) + viewModel.onInlineTextChange(" ") + + viewModel.closeInlineEdit(InlineSubtaskTarget.Rename(1, eggs.id)) + advanceUntilIdle() + + assertEquals(listOf("Milk", "Bread"), shop.map { it.title }) + assertEquals(listOf(eggs), deletions) + + viewModel.restoreSubtask(eggs) + advanceUntilIdle() + assertEquals(listOf("Milk", "Eggs", "Bread"), shop.map { it.title }) + assertEquals("u11", shop[1].uid) + } + + @Test + fun theCrossDeletesTheSubtaskBeingRenamedWithUndo() = runTest(dispatcher) { + val deletions = collect { viewModel.subtaskDeletions.collect(it::add) } + advanceUntilIdle() + val milk = shop[0] + viewModel.startRenamingSubtask(milk) + viewModel.onInlineTextChange("Changed") + + viewModel.deleteInlineSubtask() + advanceUntilIdle() + + assertNull(viewModel.inlineEdit) + assertEquals(listOf(milk), deletions) + viewModel.restoreSubtask(deletions.single()) + advanceUntilIdle() + assertEquals(listOf("Milk", "Eggs", "Bread"), shop.map { it.title }) + assertTrue(shop[0].isCompleted) + } + + @Test + fun deletingTheLastOpenSubtaskOffersToCompleteTheTask() = runTest(dispatcher) { + val offers = collect { viewModel.completionOffers.collect(it::add) } + advanceUntilIdle() + viewModel.toggleSubtask(shop[1], true) + advanceUntilIdle() + assertTrue(offers.isEmpty()) + + viewModel.startRenamingSubtask(shop.single { it.title == "Bread" }) + viewModel.deleteInlineSubtask() + advanceUntilIdle() + + assertEquals(listOf(1L), offers) + assertTrue(shop.all { it.isCompleted }) + } + + @Test + fun deletingAnOpenSubtaskWhileOthersStayOpenOffersNothing() = runTest(dispatcher) { + val offers = collect { viewModel.completionOffers.collect(it::add) } + advanceUntilIdle() + + viewModel.startRenamingSubtask(shop.single { it.title == "Bread" }) + viewModel.deleteInlineSubtask() + advanceUntilIdle() + + assertTrue(offers.isEmpty()) + } + + @Test + fun backgroundingSavesTheFieldAndKeepsItOpen() = runTest(dispatcher) { + advanceUntilIdle() + viewModel.startAddingSubtask(2) + viewModel.onInlineTextChange("Mom") + + viewModel.flushInlineEditForBackground() + viewModel.flushInlineEditForBackground() + advanceUntilIdle() + + assertEquals(listOf("Mom"), repository.subtasks.value.filter { it.taskId == 2L }.map { it.title }) + assertEquals(InlineSubtaskEdit(InlineSubtaskTarget.Add(2), ""), viewModel.inlineEdit) + + viewModel.startRenamingSubtask(shop[2]) + viewModel.onInlineTextChange("Rye bread") + viewModel.flushInlineEditForBackground() + advanceUntilIdle() + assertEquals("Rye bread", shop[2].title) + assertEquals("Rye bread", viewModel.inlineEdit?.text) + + // A blank field saves nothing, and a rename is not turned into a delete. + viewModel.onInlineTextChange("") + viewModel.flushInlineEditForBackground() + advanceUntilIdle() + assertEquals(3, shop.size) + } + + @Test + fun theSheetShowsWhatWasJustAddedInline() = runTest(dispatcher) { + advanceUntilIdle() + viewModel.startAddingSubtask(1) + viewModel.onInlineTextChange("Butter") + + viewModel.showEditTaskDialog(repository.tasks.value.single { it.id == 1L }) + advanceUntilIdle() + + assertNull(viewModel.inlineEdit) + assertEquals(listOf("Milk", "Eggs", "Bread", "Butter"), viewModel.editingSubtasks.value?.map { it.title }) + } + + @Test + fun addingToACompletedTaskLeavesItCompletedAndOffersNothing() = runTest(dispatcher) { + repository.tasks.value = repository.tasks.value.map { if (it.id == 1L) it.copy(isCompleted = true) else it } + val offers = collect { viewModel.completionOffers.collect(it::add) } + advanceUntilIdle() + + viewModel.startAddingSubtask(1) + viewModel.onInlineTextChange("Butter") + viewModel.submitInlineEdit() + advanceUntilIdle() + + assertTrue(repository.tasks.value.single { it.id == 1L }.isCompleted) + assertEquals("Butter", shop.last().title) + assertTrue(offers.isEmpty()) + } + + @Test + fun theFieldOfATaskThatIsGoneIsClosed() = runTest(dispatcher) { + advanceUntilIdle() + viewModel.startRenamingSubtask(shop[0]) + + repository.subtasks.value = repository.subtasks.value.filterNot { it.id == 10L } + advanceUntilIdle() + assertNull(viewModel.inlineEdit) + + viewModel.startAddingSubtask(2) + repository.tasks.value = repository.tasks.value.filterNot { it.id == 2L } + advanceUntilIdle() + assertNull(viewModel.inlineEdit) + } + + @Test + fun aFailedInlineWriteIsReported() = runTest(dispatcher) { + val failures = collect { viewModel.operationFailures.collect(it::add) } + advanceUntilIdle() + repository.failSubtasks = true + + viewModel.startAddingSubtask(2) + viewModel.onInlineTextChange("Mom") + viewModel.submitInlineEdit() + viewModel.startRenamingSubtask(shop[0]) + viewModel.onInlineTextChange("Oat milk") + viewModel.submitInlineEdit() + advanceUntilIdle() + + assertEquals(listOf(TaskOperationFailure.UPDATE, TaskOperationFailure.UPDATE), failures) + assertEquals("Milk", shop[0].title) + } + + private fun TestScope.collect(block: suspend (MutableList) -> Unit): List { + val items = mutableListOf() + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { block(items) } + return items + } +} diff --git a/app/src/test/java/com/pasich/encly/presentation/viewmodel/TasksViewModelSubtasksTest.kt b/app/src/test/java/com/pasich/encly/presentation/viewmodel/TasksViewModelSubtasksTest.kt index e4983a7..051e108 100644 --- a/app/src/test/java/com/pasich/encly/presentation/viewmodel/TasksViewModelSubtasksTest.kt +++ b/app/src/test/java/com/pasich/encly/presentation/viewmodel/TasksViewModelSubtasksTest.kt @@ -1,7 +1,6 @@ package com.pasich.encly.presentation.viewmodel import com.pasich.encly.data.model.Subtask -import com.pasich.encly.data.model.SubtaskProgress import com.pasich.encly.data.model.Task import com.pasich.encly.domain.usecase.task.UpdateTaskStatusUseCase import com.pasich.encly.testutil.TestTasksRepository @@ -49,10 +48,85 @@ class TasksViewModelSubtasksTest { } @Test - fun theTileProgressCountsDoneAndTotal() = runTest(dispatcher) { + fun theListGetsEachTasksSubtasksInOrder() = runTest(dispatcher) { advanceUntilIdle() - assertEquals(mapOf(1L to SubtaskProgress(1, done = 1, total = 2)), viewModel.uiState.value.subtaskProgress) + val byTask = viewModel.uiState.value.subtasks + assertEquals(setOf(1L), byTask.keys) + assertEquals(listOf("Milk", "Eggs"), byTask.getValue(1L).map { it.title }) + } + + @Test + fun tickingTheLastOpenSubtaskInTheListSavesItAndOffersToComplete() = runTest(dispatcher) { + val offers = collectOffers() + advanceUntilIdle() + val eggs = viewModel.uiState.value.subtasks.getValue(1L)[1] + + viewModel.toggleSubtask(eggs, done = true) + advanceUntilIdle() + + assertTrue(repository.subtasks.value.single { it.id == 11L }.isCompleted) + assertEquals(listOf(1L), offers) + assertFalse(repository.tasks.value.single { it.id == 1L }.isCompleted) + assertTrue(viewModel.uiState.value.subtasks.getValue(1L).all { it.isCompleted }) + assertFalse(viewModel.showAddTaskDialog.value) + } + + @Test + fun untickingInTheListSavesWithoutAnOffer() = runTest(dispatcher) { + val offers = collectOffers() + advanceUntilIdle() + val milk = viewModel.uiState.value.subtasks.getValue(1L)[0] + + viewModel.toggleSubtask(milk, done = false) + advanceUntilIdle() + + assertFalse(repository.subtasks.value.single { it.id == 10L }.isCompleted) + assertTrue(offers.isEmpty()) + } + + @Test + fun tickingAnOpenSubtaskThatLeavesOthersOpenDoesNotOffer() = runTest(dispatcher) { + repository.subtasks.value += Subtask(id = 12, taskId = 1, title = "Bread", position = 2, uid = "u12") + val offers = collectOffers() + advanceUntilIdle() + + viewModel.toggleSubtask(repository.subtasks.value.single { it.id == 11L }, done = true) + advanceUntilIdle() + + assertTrue(repository.subtasks.value.single { it.id == 11L }.isCompleted) + assertTrue(offers.isEmpty()) + } + + @Test + fun tickingTheLastSubtaskOfACompletedTaskDoesNotOffer() = runTest(dispatcher) { + repository.tasks.value = repository.tasks.value.map { if (it.id == 1L) it.copy(isCompleted = true) else it } + val offers = collectOffers() + advanceUntilIdle() + + viewModel.toggleSubtask(repository.subtasks.value.single { it.id == 11L }, done = true) + advanceUntilIdle() + + assertTrue(repository.subtasks.value.all { it.isCompleted }) + assertTrue(offers.isEmpty()) + } + + @Test + fun aFailedListTickIsReported() = runTest(dispatcher) { + val failures = mutableListOf() + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + viewModel.operationFailures.collect(failures::add) + } + val offers = collectOffers() + advanceUntilIdle() + repository.failSubtasks = true + + viewModel.toggleSubtask(repository.subtasks.value.single { it.id == 11L }, done = true) + advanceUntilIdle() + + assertEquals(listOf(TaskOperationFailure.STATUS_UPDATE), failures) + assertFalse(repository.subtasks.value.single { it.id == 11L }.isCompleted) + assertTrue(offers.isEmpty()) } @Test @@ -151,7 +225,7 @@ class TasksViewModelSubtasksTest { viewModel.restoreTask(task) advanceUntilIdle() assertEquals(before, repository.subtasks.value) - assertEquals(SubtaskProgress(1, done = 1, total = 2), viewModel.uiState.value.subtaskProgress[1L]) + assertEquals(before, viewModel.uiState.value.subtasks[1L]) } @Test diff --git a/app/src/test/java/com/pasich/encly/testutil/TestTasksRepository.kt b/app/src/test/java/com/pasich/encly/testutil/TestTasksRepository.kt index 72712c7..159a70f 100644 --- a/app/src/test/java/com/pasich/encly/testutil/TestTasksRepository.kt +++ b/app/src/test/java/com/pasich/encly/testutil/TestTasksRepository.kt @@ -1,7 +1,6 @@ package com.pasich.encly.testutil import com.pasich.encly.data.model.Subtask -import com.pasich.encly.data.model.SubtaskProgress import com.pasich.encly.data.model.Task import com.pasich.encly.domain.repository.TasksRepository import kotlinx.coroutines.flow.Flow @@ -65,10 +64,44 @@ internal class TestTasksRepository(initial: List = emptyList(), initialSub tasks.value = tasks.value.filterNot { it.isCompleted } } - override fun getSubtaskProgress(): Flow> = subtasks.map { list -> - list.groupBy { - it.taskId - }.map { (taskId, rows) -> SubtaskProgress(taskId, rows.count { it.isCompleted }, rows.size) } + override fun observeSubtasks(): Flow> = + subtasks.map { list -> list.sortedWith(compareBy({ it.taskId }, { it.position }, { it.id })) } + + override suspend fun setSubtaskCompleted(id: Long, done: Boolean): Result = write(failSubtasks) { + check(subtasks.value.any { it.id == id }) + subtasks.value = subtasks.value.map { if (it.id == id) it.copy(isCompleted = done) else it } + } + + override suspend fun addSubtask(taskId: Long, title: String): Result { + if (failSubtasks) return Result.failure(IllegalStateException("add")) + val id = nextSubtaskId++ + val position = subtasks.value.filter { it.taskId == taskId }.maxOfOrNull { it.position + 1 } ?: 0 + subtasks.value += Subtask(id = id, taskId = taskId, title = title, position = position, uid = "sub-$id") + return Result.success(id) + } + + override suspend fun renameSubtask(id: Long, title: String): Result = write(failSubtasks) { + check(subtasks.value.any { it.id == id }) + subtasks.value = subtasks.value.map { if (it.id == id) it.copy(title = title) else it } + } + + override suspend fun deleteSubtask(id: Long): Result = write(failSubtasks) { + check(subtasks.value.any { it.id == id }) + subtasks.value = subtasks.value.filterNot { it.id == id } + } + + /** Like the DAO: the rows from its position on move down one, and it gets a new id. */ + override suspend fun restoreSubtask(subtask: Subtask): Result = write(failSubtasks) { + val shifted = subtasks.value.map { + if (it.taskId == subtask.taskId && + it.position >= subtask.position + ) { + it.copy(position = it.position + 1) + } else { + it + } + } + subtasks.value = shifted + subtask.copy(id = nextSubtaskId++) } override suspend fun getSubtasks(taskId: Long): Result> = diff --git a/app/src/test/java/com/pasich/encly/ui/screens/Fakes.kt b/app/src/test/java/com/pasich/encly/ui/screens/Fakes.kt index e87d9b3..8f7104a 100644 --- a/app/src/test/java/com/pasich/encly/ui/screens/Fakes.kt +++ b/app/src/test/java/com/pasich/encly/ui/screens/Fakes.kt @@ -1,7 +1,6 @@ package com.pasich.encly.ui.screens import com.pasich.encly.data.model.Subtask -import com.pasich.encly.data.model.SubtaskProgress import com.pasich.encly.data.model.Tag import com.pasich.encly.data.model.Task import com.pasich.encly.domain.enums.NoteSortOption @@ -14,7 +13,6 @@ import com.pasich.encly.domain.repository.TagsRepository import com.pasich.encly.domain.repository.TasksRepository import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update @@ -95,6 +93,7 @@ internal class FakeTagsRepository(initial: List = emptyList()) : TagsReposi internal class FakeTasksRepository(initial: List = emptyList()) : TasksRepository { val tasks = MutableStateFlow(initial) + val subtasks = MutableStateFlow(emptyList()) override fun getAllActiveTasks(): Flow> = tasks.map { list -> list.filterNot { it.isCompleted } } override fun getAllCompletedTasks(): Flow> = tasks.map { list -> list.filter { it.isCompleted } } @@ -130,9 +129,37 @@ internal class FakeTasksRepository(initial: List = emptyList()) : TasksRep return Result.success(Unit) } - override fun getSubtaskProgress(): Flow> = flowOf(emptyList()) + override fun observeSubtasks(): Flow> = subtasks - override suspend fun getSubtasks(taskId: Long): Result> = Result.success(emptyList()) + override suspend fun setSubtaskCompleted(id: Long, done: Boolean): Result { + subtasks.update { list -> list.map { if (it.id == id) it.copy(isCompleted = done) else it } } + return Result.success(Unit) + } + + override suspend fun addSubtask(taskId: Long, title: String): Result { + val id = (subtasks.value.maxOfOrNull { it.id } ?: 0L) + 1 + val position = subtasks.value.filter { it.taskId == taskId }.maxOfOrNull { it.position + 1 } ?: 0 + subtasks.update { it + Subtask(id = id, taskId = taskId, title = title, position = position) } + return Result.success(id) + } + + override suspend fun renameSubtask(id: Long, title: String): Result { + subtasks.update { list -> list.map { if (it.id == id) it.copy(title = title) else it } } + return Result.success(Unit) + } + + override suspend fun deleteSubtask(id: Long): Result { + subtasks.update { list -> list.filterNot { it.id == id } } + return Result.success(Unit) + } + + override suspend fun restoreSubtask(subtask: Subtask): Result { + subtasks.update { list -> (list + subtask).sortedWith(compareBy({ it.taskId }, { it.position })) } + return Result.success(Unit) + } + + override suspend fun getSubtasks(taskId: Long): Result> = + Result.success(subtasks.value.filter { it.taskId == taskId }) override suspend fun saveSubtasks(taskId: Long, subtasks: List): Result = Result.success(Unit) diff --git a/app/src/test/java/com/pasich/encly/ui/screens/TasksScreenTest.kt b/app/src/test/java/com/pasich/encly/ui/screens/TasksScreenTest.kt index 9870fc7..6e9063e 100644 --- a/app/src/test/java/com/pasich/encly/ui/screens/TasksScreenTest.kt +++ b/app/src/test/java/com/pasich/encly/ui/screens/TasksScreenTest.kt @@ -1,18 +1,26 @@ package com.pasich.encly.ui.screens +import androidx.activity.OnBackPressedDispatcher +import androidx.activity.compose.LocalOnBackPressedDispatcherOwner import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.SemanticsActions import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.semantics.getOrNull import androidx.compose.ui.state.ToggleableState import androidx.compose.ui.test.SemanticsMatcher import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsFocused import androidx.compose.ui.test.assertIsNotEnabled import androidx.compose.ui.test.hasSetTextAction import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onFirst +import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performImeAction import androidx.compose.ui.test.performTextInput import com.pasich.encly.R +import com.pasich.encly.data.model.Subtask import com.pasich.encly.data.model.Task import com.pasich.encly.presentation.screen.TasksScreen import org.junit.Assert.assertEquals @@ -22,12 +30,20 @@ import org.junit.Test class TasksScreenTest : ComposeScreenTest() { private val app by lazy { TestApp(context) } + private lateinit var back: OnBackPressedDispatcher + private fun show(openNewTask: Boolean = false) { setNavScreen(viewModels(app.tasksVm()), route = TestRoutes.TASKS) { nav -> + back = LocalOnBackPressedDispatcherOwner.current!!.onBackPressedDispatcher TasksScreen(nav, openNewTask = openNewTask) } } + private fun pressBack() { + rule.runOnIdle { back.onBackPressed() } + rule.waitForIdle() + } + @Test fun noTasksShowsTheEmptyStateWithAnAction() { show() @@ -116,7 +132,7 @@ class TasksScreenTest : ComposeScreenTest() { show() waitForText("Call mum") - rule.onNodeWithText("Call mum").performClick() + rule.onNodeWithContentDescription(str(R.string.task_edit_placeholder)).performClick() waitForText(str(R.string.task_delete)) rule.onNodeWithText(str(R.string.task_delete)).performClick() @@ -126,6 +142,182 @@ class TasksScreenTest : ComposeScreenTest() { waitFor { app.tasks.tasks.value.singleOrNull()?.title == "Call mum" } } + @Test + fun foldedATaskShowsOnlyItsNextStepAndTickingItMovesOn() { + withShoppingList(Subtask(id = 12, taskId = 1, title = "Bread", position = 2)) + show() + + waitForText("Shop") + // The first open one, not the done Milk or the later Bread; and no "1/3" text anywhere. + waitForText("Eggs") + assertEquals(0, countText("Milk")) + assertEquals(0, countText("Bread")) + assertEquals(0, countText("/3", substring = true)) + assertEquals(0, countText(str(R.string.subtask_placeholder))) + + rule.onNodeWithContentDescription(str(R.string.subtask_next_step, "Eggs")).performClick() + + waitFor { app.tasks.subtasks.value.single { it.id == 11L }.isCompleted } + waitForText("Bread") + waitFor { countText("Eggs") == 0 } + // Saved in place: no sheet opened, and no completion offer while one is still open. + assertTrue(rule.onAllNodesWithText(str(R.string.save)).fetchSemanticsNodes().isEmpty()) + assertEquals(0, countText(str(R.string.subtasks_all_done))) + } + + @Test + fun tickingTheLastOpenStepKeepsItStruckAndOffersToComplete() { + withShoppingList() + show() + waitForText("Eggs") + + rule.onNodeWithContentDescription(str(R.string.subtask_next_step, "Eggs")).performClick() + + waitFor { app.tasks.subtasks.value.all { it.isCompleted } } + waitForText(str(R.string.subtasks_all_done)) + rule.onNodeWithText("Eggs").assertIsDisplayed() + assertEquals(0, countText("Milk")) + assertTrue(!app.tasks.tasks.value.single().isCompleted) + } + + @Test + fun aTaskWithoutSubtasksHasNoLeafAndOpensToFirstStep() { + app.withTasks(Task(id = 1, title = "Call mum")) + show() + waitForText("Call mum") + assertEquals(0, countText(str(R.string.subtask_first_step))) + assertEquals(1, rule.onAllNodes(isTaskCheckbox).fetchSemanticsNodes().size) + + rule.onNodeWithText("Call mum").performClick() + + waitForText(str(R.string.subtask_first_step)) + // The task row and the add leaf: no sub-task checkboxes. + assertEquals(1, rule.onAllNodes(isTaskCheckbox).fetchSemanticsNodes().size) + } + + @Test + fun aTapOnTheRowShowsTheWholeTreeAndBackFoldsIt() { + withShoppingList(Subtask(id = 12, taskId = 1, title = "Bread", position = 2)) + show() + waitForText("Eggs") + + rule.onNodeWithText("Shop").performClick() + + waitForText("Milk") + rule.onNodeWithText("Bread").assertIsDisplayed() + rule.onNodeWithText(str(R.string.subtask_placeholder)).assertIsDisplayed() + + pressBack() + + waitFor { countText("Milk") == 0 } + rule.onNodeWithText("Eggs").assertIsDisplayed() + // The screen is still there. + rule.onNodeWithText("Shop").assertIsDisplayed() + } + + @Test + fun theLeafsChevronOpensTheTree() { + withShoppingList() + show() + waitForText("Eggs") + + rule.onNodeWithContentDescription(str(R.string.subtasks_show)).performClick() + + waitForText("Milk") + rule.onNodeWithContentDescription(str(R.string.subtasks_hide)).performClick() + waitFor { countText("Milk") == 0 } + } + + @Test + fun theRowReadsItsProgressAndOffersActions() { + withShoppingList() + show() + waitForText("Shop") + val row = rule.onNodeWithText("Shop") + val config = row.fetchSemanticsNode().config + + val summary = context.resources.getQuantityString(R.plurals.subtasks_done_summary, 2, 1, 2) + assertTrue(config[SemanticsProperties.StateDescription].startsWith(summary)) + assertEquals(str(R.string.subtasks_show), config[SemanticsActions.OnClick].label) + assertEquals( + listOf(str(R.string.subtasks_show), str(R.string.task_edit_placeholder), str(R.string.subtask_add)), + config[SemanticsActions.CustomActions].map { it.label }, + ) + row.performClick() + waitFor { + row.fetchSemanticsNode().config.getOrNull(SemanticsProperties.StateDescription) + ?.endsWith(str(R.string.subtasks_state_shown)) == true + } + assertEquals(str(R.string.subtasks_hide), row.fetchSemanticsNode().config[SemanticsActions.OnClick].label) + } + + @Test + fun theFirstStepTurnsIntoAFieldThatAddsOnDone() { + app.withTasks(Task(id = 1, title = "Call mum")) + show() + waitForText("Call mum") + rule.onNodeWithText("Call mum").performClick() + waitForText(str(R.string.subtask_first_step)) + + rule.onNodeWithText(str(R.string.subtask_first_step)).performClick() + rule.waitForIdle() + val field = rule.onNode(hasSetTextAction()) + field.assertIsFocused() + field.performTextInput("Birthday") + field.performImeAction() + + waitFor { app.tasks.subtasks.value.singleOrNull()?.title == "Birthday" } + waitForText("Birthday") + // Still open, empty and focused, for the next one; the FAB is out of the way meanwhile. + rule.onNode(hasSetTextAction()).assertIsFocused() + assertEquals(0, countText(str(R.string.task_add))) + + // Back closes the field first, and only then folds the tree. + pressBack() + waitFor { rule.onAllNodes(hasSetTextAction()).fetchSemanticsNodes().isEmpty() } + rule.onNodeWithText(str(R.string.subtask_placeholder)).assertIsDisplayed() + pressBack() + waitFor { countText(str(R.string.subtask_placeholder)) == 0 } + // Folded, it now shows its next step like any task with sub-tasks. + rule.onNodeWithText("Birthday").assertIsDisplayed() + } + + @Test + fun aDoneTaskShowsNoNextStepUntilOpened() { + app.withTasks(Task(id = 1, title = "Shop", isCompleted = true, completedDate = 1L)) + app.tasks.subtasks.value = listOf(Subtask(id = 11, taskId = 1, title = "Eggs", position = 0)) + show() + waitForText("Shop") + assertEquals(0, countText("Eggs")) + + rule.onNodeWithText("Shop").performClick() + + waitForText("Eggs") + rule.onNodeWithContentDescription("Eggs").performClick() + waitFor { app.tasks.subtasks.value.single().isCompleted } + } + + @Test + fun theEditButtonOpensTheSheet() { + withShoppingList() + show() + waitForText("Shop") + + rule.onNodeWithContentDescription(str(R.string.task_edit_placeholder)).performClick() + + waitForText(str(R.string.task_delete)) + // The sheet with the task's checklist. + waitForText("Milk") + } + + private fun withShoppingList(vararg more: Subtask) { + app.withTasks(Task(id = 1, title = "Shop")) + app.tasks.subtasks.value = listOf( + Subtask(id = 10, taskId = 1, title = "Milk", isCompleted = true, position = 0), + Subtask(id = 11, taskId = 1, title = "Eggs", position = 1), + ) + more + } + private companion object { /** A task's own checkbox; the filter chips are checkboxes too, but with a label. */ val isTaskCheckbox = SemanticsMatcher.expectValue(SemanticsProperties.Role, Role.Checkbox) and From 887bea1129e846c0c512902d5dcd97fb8256f175 Mon Sep 17 00:00:00 2001 From: pasichdev Date: Mon, 5 Oct 2026 11:25:45 +0300 Subject: [PATCH 07/19] feat(security): say that the wipe PIN opens the vault after an erase After an erase the wipe PIN is the PIN of the new, empty vault and the old PIN is wrong, so whoever saw it typed can open Encly with it again and sees nothing unusual. The wipe PIN screen did not say so, and it read as a broken PIN on the first try. It now says it in a warning callout, in all nine languages. A test covers the cold start after an erase: the wipe PIN opens the empty vault with the key it was created with, and the old PIN is refused. Refs: #52 --- .../screen/settings/WipePinScreen.kt | 1 + .../main/res/values-de/strings_security.xml | 1 + .../main/res/values-es/strings_security.xml | 1 + .../main/res/values-fr/strings_security.xml | 1 + .../main/res/values-it/strings_security.xml | 1 + .../main/res/values-nl/strings_security.xml | 1 + .../main/res/values-pl/strings_security.xml | 1 + .../main/res/values-pt/strings_security.xml | 1 + .../main/res/values-uk/strings_security.xml | 1 + app/src/main/res/values/strings_security.xml | 1 + .../pasich/encly/core/security/WipePinTest.kt | 33 +++++++++++++++++++ 11 files changed, 43 insertions(+) diff --git a/app/src/main/java/com/pasich/encly/presentation/screen/settings/WipePinScreen.kt b/app/src/main/java/com/pasich/encly/presentation/screen/settings/WipePinScreen.kt index 63b27f0..436e2fc 100644 --- a/app/src/main/java/com/pasich/encly/presentation/screen/settings/WipePinScreen.kt +++ b/app/src/main/java/com/pasich/encly/presentation/screen/settings/WipePinScreen.kt @@ -275,6 +275,7 @@ private fun WipePinOptions(info: WipePinInfo, actions: WipePinActions, modifier: style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurface, ) + EnclyCallout(text = stringResource(R.string.wipe_pin_after_erase), tone = CalloutTone.WARNING) EnclyCallout(text = stringResource(R.string.wipe_pin_backups)) if (info.biometricOn) { Column(verticalArrangement = Arrangement.spacedBy(spacing.xxs)) { diff --git a/app/src/main/res/values-de/strings_security.xml b/app/src/main/res/values-de/strings_security.xml index 0d711c8..e11dcac 100644 --- a/app/src/main/res/values-de/strings_security.xml +++ b/app/src/main/res/values-de/strings_security.xml @@ -73,6 +73,7 @@ Eine zweite PIN, die deine Notizen löscht, wenn du sie auf dem Sperrbildschirm eingibst Ausgeschaltet, als dein PIN-Schlüssel zurückgesetzt wurde. Lege sie neu fest, wenn du eine hattest. Wenn dich jemand zwingt, Encly zu öffnen, gib statt deiner PIN die Lösch-PIN ein. Encly löscht deine Notizen, Aufgaben und Labels ohne sichtbares Zeichen und öffnet sich als leerer Tresor. Das funktioniert nur auf dem Sperrbildschirm. + Nach dem Löschen öffnet sich Encly mit der Lösch-PIN: Sie wird zur PIN des neuen, leeren Tresors. Deine alte PIN funktioniert nicht mehr. Wer gesehen hat, wie du die Lösch-PIN eingibst, kann Encly damit wieder öffnen und bemerkt nichts Ungewöhnliches. Bereits exportierte Backup-Dateien bleiben unberührt. Sie lassen sich weiterhin mit deiner Wiederherstellungsphrase öffnen und sind der einzige Weg zurück. Biometrisches Entsperren bleibt an: Jemand kann deinen Finger auf den Sensor zwingen. Schalte es aus, wenn das für dich ein Risiko ist. Biometrisches Entsperren ausschalten diff --git a/app/src/main/res/values-es/strings_security.xml b/app/src/main/res/values-es/strings_security.xml index 0d5321f..06ae697 100644 --- a/app/src/main/res/values-es/strings_security.xml +++ b/app/src/main/res/values-es/strings_security.xml @@ -78,6 +78,7 @@ Un segundo PIN que borra tus notas si lo escribes en la pantalla de bloqueo Se desactivó al restablecerse la clave de tu PIN. Vuelve a configurarlo si lo usabas. Si alguien te obliga a abrir Encly, escribe el PIN de borrado en lugar de tu PIN. Encly borra tus notas, tareas y etiquetas sin dejar señal y se abre como una bóveda vacía. Solo funciona en la pantalla de bloqueo. + Tras el borrado, Encly se abre con el PIN de borrado: pasa a ser el PIN de la nueva bóveda vacía. Tu PIN anterior deja de funcionar, así que quien te vio escribir el PIN de borrado puede volver a abrir Encly con él sin notar nada raro. Los archivos de copia que ya exportaste no se tocan. Se siguen abriendo con tu frase de recuperación y son la única forma de volver atrás. El desbloqueo biométrico sigue activado: alguien puede obligarte a poner el dedo en el sensor. Desactívalo si eso es un riesgo para ti. Desactivar el desbloqueo biométrico diff --git a/app/src/main/res/values-fr/strings_security.xml b/app/src/main/res/values-fr/strings_security.xml index fe62051..c28516b 100644 --- a/app/src/main/res/values-fr/strings_security.xml +++ b/app/src/main/res/values-fr/strings_security.xml @@ -78,6 +78,7 @@ Un second PIN qui efface vos notes s\'il est saisi sur l\'écran de verrouillage Désactivé lors de la réinitialisation de la clé de votre PIN. Définissez-le à nouveau si vous en utilisiez un. Si quelqu\'un vous force à ouvrir Encly, saisissez le PIN d\'effacement au lieu de votre PIN. Encly efface vos notes, tâches et libellés sans aucun signe et s\'ouvre comme un coffre vide. Cela ne fonctionne que sur l\'écran de verrouillage. + Après l\'effacement, Encly s\'ouvre avec le PIN d\'effacement : il devient le PIN du nouveau coffre vide. Votre ancien PIN ne fonctionne plus. La personne qui vous a vu saisir le PIN d\'effacement peut rouvrir Encly avec et ne remarque rien d\'inhabituel. Les fichiers de sauvegarde déjà exportés ne sont pas touchés. Ils s\'ouvrent toujours avec votre phrase de récupération et sont le seul moyen de revenir en arrière. Le déverrouillage biométrique reste actif : quelqu\'un peut vous forcer à poser le doigt sur le capteur. Désactivez-le si c\'est un risque pour vous. Désactiver le déverrouillage biométrique diff --git a/app/src/main/res/values-it/strings_security.xml b/app/src/main/res/values-it/strings_security.xml index 37152d3..22e2043 100644 --- a/app/src/main/res/values-it/strings_security.xml +++ b/app/src/main/res/values-it/strings_security.xml @@ -78,6 +78,7 @@ Un secondo PIN che cancella le tue note se lo digiti nella schermata di blocco Disattivato quando la chiave del PIN è stata reimpostata. Impostalo di nuovo se ne usavi uno. Se qualcuno ti costringe ad aprire Encly, digita il PIN di cancellazione invece del tuo PIN. Encly cancella note, attività ed etichette senza lasciare segni e si apre come una cassaforte vuota. Funziona solo nella schermata di blocco. + Dopo la cancellazione, Encly si apre con il PIN di cancellazione: diventa il PIN della nuova cassaforte vuota. Il vecchio PIN smette di funzionare, così chi ti ha visto digitare il PIN di cancellazione può riaprire Encly con esso senza notare nulla di strano. I file di backup che hai già esportato non vengono toccati. Si aprono ancora con la frase di recupero e sono l\'unico modo per tornare indietro. Lo sblocco biometrico resta attivo: qualcuno può costringerti ad appoggiare il dito sul sensore. Disattivalo se per te è un rischio. Disattiva lo sblocco biometrico diff --git a/app/src/main/res/values-nl/strings_security.xml b/app/src/main/res/values-nl/strings_security.xml index 4eec494..845d461 100644 --- a/app/src/main/res/values-nl/strings_security.xml +++ b/app/src/main/res/values-nl/strings_security.xml @@ -73,6 +73,7 @@ Een tweede pincode die je notities wist als je hem op het vergrendelscherm invoert Uitgeschakeld toen de sleutel van je pincode werd gereset. Stel hem opnieuw in als je er een gebruikte. Als iemand je dwingt Encly te openen, voer dan de wis-pincode in plaats van je pincode in. Encly wist je notities, taken en labels zonder zichtbaar teken en opent als een lege kluis. Dit werkt alleen op het vergrendelscherm. + Na het wissen opent Encly met de wis-pincode: die wordt de pincode van de nieuwe, lege kluis. Je oude pincode werkt niet meer. Wie je de wis-pincode zag invoeren, kan Encly er opnieuw mee openen en merkt niets ongewoons. Back-upbestanden die je al hebt geëxporteerd, blijven onaangeroerd. Ze openen nog steeds met je herstelzin en zijn de enige weg terug. Biometrisch ontgrendelen blijft aan: iemand kan je vinger op de sensor dwingen. Zet het uit als dat voor jou een risico is. Biometrisch ontgrendelen uitzetten diff --git a/app/src/main/res/values-pl/strings_security.xml b/app/src/main/res/values-pl/strings_security.xml index 6e310cb..c676477 100644 --- a/app/src/main/res/values-pl/strings_security.xml +++ b/app/src/main/res/values-pl/strings_security.xml @@ -83,6 +83,7 @@ Drugi PIN, który kasuje notatki, gdy wpiszesz go na ekranie blokady Wyłączony po zresetowaniu klucza PIN. Ustaw go ponownie, jeśli go używałeś. Jeśli ktoś zmusza cię do otwarcia Encly, wpisz PIN kasujący zamiast swojego PIN-u. Encly bez żadnego śladu skasuje notatki, zadania i etykiety i otworzy się jako pusty sejf. Działa tylko na ekranie blokady. + Po skasowaniu Encly otwiera się PIN-em kasującym: staje się on PIN-em nowego, pustego sejfu. Twój stary PIN przestaje działać, więc ktoś, kto widział, jak wpisujesz PIN kasujący, może nim znów otworzyć Encly i nie zauważy nic dziwnego. Wyeksportowane już pliki kopii zapasowych pozostają nietknięte. Nadal otwierają się frazą odzyskiwania i są jedyną drogą powrotu. Odblokowanie biometryczne pozostaje włączone: ktoś może zmusić cię do przyłożenia palca do czytnika. Wyłącz je, jeśli to dla ciebie ryzyko. Wyłącz odblokowanie biometryczne diff --git a/app/src/main/res/values-pt/strings_security.xml b/app/src/main/res/values-pt/strings_security.xml index 0034747..1b9da14 100644 --- a/app/src/main/res/values-pt/strings_security.xml +++ b/app/src/main/res/values-pt/strings_security.xml @@ -78,6 +78,7 @@ Um segundo PIN que apaga suas notas quando digitado na tela de bloqueio Desativado quando a chave do seu PIN foi redefinida. Defina-o de novo se você usava um. Se alguém obrigar você a abrir o Encly, digite o PIN de apagamento em vez do seu PIN. O Encly apaga suas notas, tarefas e etiquetas sem deixar sinal e abre como um cofre vazio. Só funciona na tela de bloqueio. + Depois do apagamento, o Encly abre com o PIN de apagamento: ele passa a ser o PIN do novo cofre vazio. Seu PIN antigo deixa de funcionar, então quem viu você digitar o PIN de apagamento pode abrir o Encly com ele de novo sem notar nada estranho. Os arquivos de backup que você já exportou não são tocados. Eles continuam abrindo com sua frase de recuperação e são o único caminho de volta. O desbloqueio biométrico continua ativado: alguém pode forçar seu dedo no sensor. Desative-o se isso for um risco para você. Desativar desbloqueio biométrico diff --git a/app/src/main/res/values-uk/strings_security.xml b/app/src/main/res/values-uk/strings_security.xml index 2179fa3..9b1e242 100644 --- a/app/src/main/res/values-uk/strings_security.xml +++ b/app/src/main/res/values-uk/strings_security.xml @@ -83,6 +83,7 @@ Другий PIN, що стирає нотатки, якщо ввести його на екрані блокування Вимкнено після скидання ключа PIN. Задайте його знову, якщо користувалися ним. Якщо вас змушують відкрити Encly, введіть PIN для стирання замість свого PIN. Encly непомітно зітре нотатки, завдання й мітки та відкриється як порожнє сховище. Працює лише на екрані блокування. + Після стирання Encly відкривається PIN для стирання: він стає PIN нового, порожнього сховища. Ваш старий PIN більше не працює. Тож той, хто бачив, як ви вводили PIN для стирання, зможе знову відкрити ним Encly й не помітить нічого дивного. Уже експортовані файли резервних копій не зачіпаються. Вони й далі відкриваються фразою відновлення і є єдиним шляхом назад. Розблокування біометрією лишається ввімкненим: вас можуть змусити прикласти палець. Вимкніть його, якщо це для вас ризик. Вимкнути розблокування біометрією diff --git a/app/src/main/res/values/strings_security.xml b/app/src/main/res/values/strings_security.xml index 4e95529..8ab4c27 100644 --- a/app/src/main/res/values/strings_security.xml +++ b/app/src/main/res/values/strings_security.xml @@ -73,6 +73,7 @@ A second PIN that erases your notes when typed on the lock screen Turned off when your PIN key was reset. Set it again if you used one. If someone makes you open Encly, type the wipe PIN instead of your PIN. Encly erases your notes, tasks and tags without any sign and opens as an empty vault. It works only on the lock screen. + After an erase, Encly opens with the wipe PIN: it becomes the PIN of the new, empty vault. Your old PIN stops working, so whoever saw you type the wipe PIN can open Encly with it again and sees nothing unusual. Backup files you already exported are not touched. They still open with your recovery phrase and are the only way back. Biometric unlock stays on: someone can force your finger onto the sensor. Turn it off if that is a risk for you. Turn off biometric unlock diff --git a/app/src/test/java/com/pasich/encly/core/security/WipePinTest.kt b/app/src/test/java/com/pasich/encly/core/security/WipePinTest.kt index 390bcbf..eaaeaaa 100644 --- a/app/src/test/java/com/pasich/encly/core/security/WipePinTest.kt +++ b/app/src/test/java/com/pasich/encly/core/security/WipePinTest.kt @@ -403,6 +403,39 @@ class WipePinTest { assertFalse(vault.security.isErasedVaultSession()) } + @Test + fun afterTheWipeAColdStartOpensTheEmptyVaultWithTheWipePinAndNotTheOldPin() { + val vault = Vault() + val keys = mutableListOf>() + `when`(vault.database.unlockDatabase(anyByteArray(), anyBoolean())).thenAnswer { + keys += (it.arguments[0] as ByteArray).copyOf() to (it.arguments[1] as Boolean) + true + } + assertEquals(VaultUnlockResult.SUCCESS, vault.security.unlockWithPin(pin(WIPE_PIN))) + val (emptyVaultKey, created) = keys.single() + assertTrue(created) + vault.security.lock() + + // A new process: every file read back from disk. + val store = VaultStore(file) + val auth = AuthenticationManager(store, factor, clock) + val database: SecureDatabaseManager = mock(SecureDatabaseManager::class.java) + `when`(database.hasEncryptedDatabase()).thenReturn(true) + `when`(database.unlockDatabase(anyByteArray(), anyBoolean())).thenAnswer { + keys += (it.arguments[0] as ByteArray).copyOf() to (it.arguments[1] as Boolean) + true + } + val security = SecurityManager(vault.prefs, store, SeedPhraseManager(store), database, auth, vault.biometric) + assertEquals(InitialStatus.AUTH, security.resolveInitialStatus()) + + assertEquals(VaultUnlockResult.INVALID_CREDENTIAL, security.unlockWithPin(pin(PIN))) + assertEquals(VaultUnlockResult.SUCCESS, security.unlockWithPin(pin(WIPE_PIN))) + val (reopenKey, reopenCreates) = keys.last() + assertFalse(reopenCreates) + assertArrayEquals(emptyVaultKey, reopenKey) + verify(database, never()).wipe() + } + @Test fun aNormalUnlockIsNotAnErasedVaultSession() { val vault = Vault() From af8cdd9bd8e409304a103df247d9a22142037291 Mon Sep 17 00:00:00 2001 From: pasichdev Date: Mon, 5 Oct 2026 11:25:45 +0300 Subject: [PATCH 08/19] chore(debug): name debug builds "Encly Debug" A debug build installs next to the release app under its own application id, with the same name and icon, so the two were easy to mix up on a test phone. --- app/src/debug/res/values/strings.xml | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 app/src/debug/res/values/strings.xml diff --git a/app/src/debug/res/values/strings.xml b/app/src/debug/res/values/strings.xml new file mode 100644 index 0000000..a2b22cc --- /dev/null +++ b/app/src/debug/res/values/strings.xml @@ -0,0 +1,5 @@ + + + + Encly Debug + From 94d2ebab2cf21ce79ff454cbb4e4c92ae063e57f Mon Sep 17 00:00:00 2001 From: pasichdev Date: Mon, 5 Oct 2026 11:54:59 +0300 Subject: [PATCH 09/19] fix(security): turn the wipe PIN off when the PIN key had to be made anew Setting a PIN makes the device-bound PIN key first if it is missing. A key the system invalidated was already treated as a reset, which turns the wipe PIN off and says so in Settings. A key that was simply gone (deleted rather than invalidated) was created silently instead: the old wipe slot was kept although it was sealed with the lost key, so the wipe PIN stopped working and nothing said so. PinHardwareFactor.ensureKey now reports whether it had to create the key, and configurePin counts that as a reset. A test deletes the key of a vault with a wipe PIN and checks that the next PIN turns the wipe PIN off with the notice. Refs: #52 --- SECURITY.md | 5 +++-- .../encly/core/security/AuthenticationManager.kt | 8 +++++--- .../encly/core/security/PinHardwareFactor.kt | 11 ++++++++--- .../com/pasich/encly/core/security/WipePinTest.kt | 15 +++++++++++++++ .../com/pasich/encly/testutil/SecurityFakes.kt | 11 +++++++---- 5 files changed, 38 insertions(+), 12 deletions(-) diff --git a/SECURITY.md b/SECURITY.md index 13c426c..21c4a60 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -71,8 +71,9 @@ and show me"), not against a forensic examination. KEKs and always tries both AES-GCM opens, without early exit. - **Rules.** 6 digits and different from the PIN (checked by opening the PIN slot with it). Changing the PIN to the wipe PIN turns the wipe PIN off (one PIN never opens both slots). A - reset of the PIN's Keystore key (see above) turns it off too, since the slot can never open - again; Settings then says so. + reset of the PIN's Keystore key (see above), or a new PIN set after that key was deleted and + had to be created anew, turns it off too, since the slot can never open again; Settings then + says so. - **Lockout.** It counts as an attempt before the KDF like any PIN and is refused during a lockout. Once it matched, the erase clears the lockout like a right PIN. Inside an open vault (PIN re-checks in Settings or before an export) it is just a wrong PIN; it wipes only from the diff --git a/app/src/main/java/com/pasich/encly/core/security/AuthenticationManager.kt b/app/src/main/java/com/pasich/encly/core/security/AuthenticationManager.kt index 5fe147c..f5d3bb4 100644 --- a/app/src/main/java/com/pasich/encly/core/security/AuthenticationManager.kt +++ b/app/src/main/java/com/pasich/encly/core/security/AuthenticationManager.kt @@ -115,8 +115,8 @@ class AuthenticationManager @Inject constructor( * * A replaced slot keeps its salt, so a wipe PIN set earlier keeps working. The wipe slot is * replaced by a decoy (the wipe PIN turned off) when it can no longer open, because the - * PIN key had to be reset (Settings then says so), or must not, because [pin] is the wipe - * PIN itself: one PIN never opens both slots. + * PIN key had to be reset or was gone and had to be made anew (Settings then says so), or + * must not, because [pin] is the wipe PIN itself: one PIN never opens both slots. */ @Suppress("ReturnCount") // Validation gates fail closed before any key is touched. fun configurePin(pin: CharArray, dek: ByteArray): Boolean { @@ -126,7 +126,9 @@ class AuthenticationManager @Inject constructor( val salt = keptSalt ?: ByteArray(PIN_SALT_SIZE).also { SecureRandom().nextBytes(it) } var keyReset = false val keys = try { - factor.ensureKey(keySlot) + // A key made here (deleted by the system, not just invalidated) is a reset too: the + // wipe slot was sealed with the one that is gone. + keyReset = factor.ensureKey(keySlot) try { derivePinKeys(pin, salt, keySlot) } catch (e: PinFactorException) { diff --git a/app/src/main/java/com/pasich/encly/core/security/PinHardwareFactor.kt b/app/src/main/java/com/pasich/encly/core/security/PinHardwareFactor.kt index 61a82b9..5042e4d 100644 --- a/app/src/main/java/com/pasich/encly/core/security/PinHardwareFactor.kt +++ b/app/src/main/java/com/pasich/encly/core/security/PinHardwareFactor.kt @@ -35,9 +35,13 @@ enum class PinKeySlot { * can replace the Keystore. */ interface PinHardwareFactor { - /** Creates the key in [slot] unless it already exists. */ + /** + * Creates the key in [slot] unless it already exists. True when it had to be created: any + * slot sealed with a key that was there before (a key the system or a backup restore deleted) + * can no longer be opened. + */ @Throws(PinFactorException::class) - fun ensureKey(slot: PinKeySlot) + fun ensureKey(slot: PinKeySlot): Boolean /** Replaces the key in [slot] with a new one; slots sealed with the old one can no longer be opened. */ @Throws(PinFactorException::class) @@ -70,7 +74,7 @@ class PinFactorException(val lost: Boolean, cause: Throwable? = null) : Exceptio class KeystorePinFactor @Inject constructor() : PinHardwareFactor { private val keyStore by lazy { KeyStore.getInstance(KEYSTORE_PROVIDER).apply { load(null) } } - override fun ensureKey(slot: PinKeySlot) { + override fun ensureKey(slot: PinKeySlot): Boolean { val exists = try { keyStore.containsAlias(alias(slot)) } catch (e: GeneralSecurityException) { @@ -79,6 +83,7 @@ class KeystorePinFactor @Inject constructor() : PinHardwareFactor { throw PinFactorException(lost = false, cause = e) } if (!exists) reset(slot) + return !exists } override fun reset(slot: PinKeySlot) { diff --git a/app/src/test/java/com/pasich/encly/core/security/WipePinTest.kt b/app/src/test/java/com/pasich/encly/core/security/WipePinTest.kt index eaaeaaa..1512d0b 100644 --- a/app/src/test/java/com/pasich/encly/core/security/WipePinTest.kt +++ b/app/src/test/java/com/pasich/encly/core/security/WipePinTest.kt @@ -301,6 +301,21 @@ class WipePinTest { assertTrue(auth.unlockWithPin(pin(WIPE_PIN)) is PinUnlock.Erased) } + @Test + fun aPinKeyTheSystemDeletedTurnsTheWipePinOffAndSaysSo() { + assertTrue(auth.configurePin(pin(PIN), dek)) + assertEquals(WipePinChange.SET, auth.configureWipePin(pin(WIPE_PIN))) + // Not invalidated but gone: setting a PIN has to make a new key, which the wipe slot was + // not sealed with. + factor.delete(PinKeySlot.A) + + assertTrue(auth.configurePin(pin("246810"), dek)) + + assertTrue(auth.wipePinTurnedOff()) + assertEquals(PinUnlock.WrongPin, auth.unlockWithPin(pin(WIPE_PIN))) + assertArrayEquals(dek, (auth.unlockWithPin(pin("246810")) as PinUnlock.Success).dek) + } + @Test fun removingTheWipePinAlsoClearsTheNotice() { assertTrue(auth.configurePin(pin(PIN), dek)) diff --git a/app/src/test/java/com/pasich/encly/testutil/SecurityFakes.kt b/app/src/test/java/com/pasich/encly/testutil/SecurityFakes.kt index da595a7..4769d26 100644 --- a/app/src/test/java/com/pasich/encly/testutil/SecurityFakes.kt +++ b/app/src/test/java/com/pasich/encly/testutil/SecurityFakes.kt @@ -14,8 +14,9 @@ import javax.crypto.spec.SecretKeySpec /** * A [PinHardwareFactor] with software HMAC keys standing in for the Keystore ones, one per * [PinKeySlot]. [lost] and [failing] simulate an invalidated key and a transient Keystore error - * (for every slot); [failingReset] a key that cannot be generated. [calls] counts MACs, i.e. - * PIN guesses that reached the "hardware". + * (for every slot); [failingReset] a key that cannot be generated. [delete] removes a key the + * way the system can, so [ensureKey] then reports a new one. [calls] counts MACs, i.e. PIN + * guesses that reached the "hardware". */ internal class FakePinFactor : PinHardwareFactor { private val keys = mutableMapOf() @@ -29,8 +30,10 @@ internal class FakePinFactor : PinHardwareFactor { fun hasKey(slot: PinKeySlot): Boolean = slot in keys - override fun ensureKey(slot: PinKeySlot) { - if (slot !in keys) reset(slot) + override fun ensureKey(slot: PinKeySlot): Boolean { + val missing = slot !in keys + if (missing) reset(slot) + return missing } override fun reset(slot: PinKeySlot) { From 793be8623ed8048bfc4105d03f19bc89f707a547 Mon Sep 17 00:00:00 2001 From: pasichdev Date: Mon, 5 Oct 2026 12:14:23 +0300 Subject: [PATCH 10/19] fix(lock): never reopen a note from before a wipe-PIN erase After an unlock, the note that was open when the app re-locked was reopened unless the open session was the one the wipe PIN had made. That guard was cleared by every lock, so a re-lock after the erase, an erase whose own open failed and was retried with the PIN of the new slot, going to the background while the vault was revealed, or an erase on the My Notes hand-off's lock screen could still reopen the old note's id in the empty vault: an empty editor that gives the erase away. SecurityManager now keeps an erase epoch. It changes whenever an erase creates the empty database, never on lock or unlock, and starts at a random value in each process. The editor records the epoch with its note, the re-lock copies both onto the lock screen (and forgets them when the screen left was not an editor), and the unlock reopens the note only while the epoch is unchanged. SessionLockManager counts published unlocks; MainActivity notes the count when it stops and, if the vault was unlocked on another lock screen meanwhile, shows the lock screen or a fresh Home instead of screens whose ViewModels have dropped their content. After a process restart the note is no longer reopened, which the changelog says. Refs: #52 --- CHANGELOG.md | 2 + SECURITY.md | 5 ++ .../java/com/pasich/encly/MainActivity.kt | 77 +++++++++++++++++-- .../encly/core/security/SecurityManager.kt | 42 +++++----- .../encly/core/security/SessionLockManager.kt | 10 +++ .../encly/presentation/navigation/NavHost.kt | 14 +++- .../presentation/navigation/RelockReturn.kt | 48 +++++++++++- .../encly/presentation/screen/LockScreen.kt | 11 ++- .../presentation/viewmodel/LockViewModel.kt | 7 +- .../core/security/SessionLockManagerTest.kt | 17 ++++ .../pasich/encly/core/security/WipePinTest.kt | 26 ++++++- .../navigation/RelockReturnTest.kt | 14 ++++ .../viewmodel/LockViewModelTest.kt | 9 +++ config/detekt/detekt.yml | 2 + docs/architecture.md | 8 +- 15 files changed, 243 insertions(+), 49 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c770ca7..e439c3f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,6 +51,8 @@ IzzyOnDroid) and used as the GitHub Release notes. ### Changed - Changing the PIN keeps the PIN slot's salt. +- When Encly was restarted in the background while locked, unlocking opens the notes list + instead of the note that was open. ## [2.0.1] - 2026-09-25 diff --git a/SECURITY.md b/SECURITY.md index 21c4a60..8e82b4e 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -104,6 +104,11 @@ and show me"), not against a forensic examination. from the lock screen would itself be a sign). Someone can force a finger onto the sensor and open the real vault, so the settings page warns about it and offers to turn biometric unlock off. +- **After the erase** nothing of the old vault reappears: the note that was open when the app + re-locked is reopened after an unlock only in the vault it was opened in (each erase starts a + new epoch, and a restarted process starts one that matches nothing), and a screen that was in + the background while the vault was unlocked elsewhere (the My Notes hand-off's lock screen) + is replaced by the lock screen or the notes list when it comes back. - **Unlock time.** The wipe path adds a Keystore key generation, one HMAC, a store write and creating the empty database to the same KDF run; the empty vault opens behind the same unlock animation. diff --git a/app/src/main/java/com/pasich/encly/MainActivity.kt b/app/src/main/java/com/pasich/encly/MainActivity.kt index 5bec0d9..e461e0d 100644 --- a/app/src/main/java/com/pasich/encly/MainActivity.kt +++ b/app/src/main/java/com/pasich/encly/MainActivity.kt @@ -31,6 +31,9 @@ import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalView import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.lifecycleScope import androidx.navigation.NavController import androidx.navigation.NavHostController @@ -46,8 +49,10 @@ import com.pasich.encly.presentation.effects.LocalUnlockReveal import com.pasich.encly.presentation.effects.UnlockRevealOverlay import com.pasich.encly.presentation.effects.UnlockRevealState import com.pasich.encly.presentation.navigation.AppNavHost +import com.pasich.encly.presentation.navigation.LocalEraseEpoch import com.pasich.encly.presentation.navigation.NavRoutes import com.pasich.encly.presentation.navigation.RelockReturn +import com.pasich.encly.presentation.navigation.SessionResume import com.pasich.encly.ui.theme.AppTheme import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.Dispatchers @@ -126,6 +131,7 @@ class MainActivity : AppCompatActivity() { val locked by sessionLockManager.locked.collectAsState() val strictKeyboard by keyboardPrivacy.strict.collectAsState() LockRouteGuard(navController, securityManager::isDatabaseUnlocked) + SessionResumeGuard(navController, sessionLockManager) val visibleEntries by navController.visibleEntries.collectAsState() val shielded = locked && visibleEntries.any { it.destination.route != NavRoutes.LockRoute.name @@ -137,6 +143,7 @@ class MainActivity : AppCompatActivity() { App( navController = navController, startDestination = destination.name, + eraseEpoch = securityManager::eraseEpoch, shielded = shielded, locked = locked, strictKeyboard = strictKeyboard, @@ -202,6 +209,46 @@ private fun LockRouteGuard(navController: NavHostController, isDatabaseUnlocked: } } +/** + * Coming back to the foreground after the vault was unlocked on another lock screen (see + * [SessionResume.afterStop]) rebuilds the back stack instead of showing screens of a session + * that ended. Runs on ON_START itself, before the first frame and the re-lock effect. + */ +@Composable +private fun SessionResumeGuard(navController: NavHostController, sessionLockManager: SessionLockManager) { + val lifecycleOwner = LocalLifecycleOwner.current + DisposableEffect(lifecycleOwner, navController) { + var stoppedAt: Long? = null + val observer = LifecycleEventObserver { _, event -> + when (event) { + Lifecycle.Event.ON_STOP -> stoppedAt = sessionLockManager.sessionGeneration + + Lifecycle.Event.ON_START -> { + val resume = SessionResume.afterStop( + stoppedAt = stoppedAt, + generation = sessionLockManager.sessionGeneration, + locked = sessionLockManager.locked.value, + ) + stoppedAt = null + when (resume) { + SessionResume.KEEP -> Unit + + SessionResume.LOCK_SCREEN -> navController.showLockScreen(keepNote = false) + + SessionResume.HOME -> navController.navigate(NavRoutes.HomeRoute.name) { + popUpTo(navController.graph.id) { inclusive = true } + } + } + } + + else -> Unit + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } + } +} + /** Navigation's own deep-link extras; the exported launcher activity must not honour them. */ private const val NAV_EXTRA_PREFIX = "android-support-nav:controller:" @@ -225,16 +272,20 @@ private const val EDIT_NOTE_READ_ONLY_ARG = "isReadTrashOnly" /** * Replaces every screen with the lock screen after a background re-lock, remembering the note - * that was open (see [RelockReturn]) so the unlock can return to it. + * that was open (see [RelockReturn]) with the erase epoch of the vault it was opened in, so the + * unlock returns to it only in that vault. Without [keepNote], or from any other screen, + * whatever was remembered before is forgotten. */ -private fun NavHostController.showLockScreen() { - val returnRoute = currentBackStackEntry?.let { entry -> +private fun NavHostController.showLockScreen(keepNote: Boolean = true) { + val editor = currentBackStackEntry?.takeIf { keepNote } + val returnRoute = editor?.let { entry -> RelockReturn.routeFor( destinationRoute = entry.destination.route, openNoteId = entry.savedStateHandle.get(RelockReturn.OPEN_NOTE_ID), readOnly = entry.arguments?.getBoolean(EDIT_NOTE_READ_ONLY_ARG) == true, ) } + val returnEpoch = editor?.savedStateHandle?.get(RelockReturn.OPEN_NOTE_EPOCH) navigate(NavRoutes.LockRoute.name) { // Drop every screen/ViewModel backed by the now-closed Room instance. // Unlock starts a fresh Home graph with fresh DAO flows. @@ -243,13 +294,21 @@ private fun NavHostController.showLockScreen() { } launchSingleTop = true } - if (returnRoute != null) currentBackStackEntry?.savedStateHandle?.set(RelockReturn.RETURN_ROUTE, returnRoute) + val lockState = currentBackStackEntry?.savedStateHandle ?: return + if (returnRoute != null && returnEpoch != null) { + lockState[RelockReturn.RETURN_ROUTE] = returnRoute + lockState[RelockReturn.RETURN_EPOCH] = returnEpoch + } else { + lockState.remove(RelockReturn.RETURN_ROUTE) + lockState.remove(RelockReturn.RETURN_EPOCH) + } } @Composable fun App( navController: NavHostController, startDestination: String, + eraseEpoch: () -> Long, modifier: Modifier = Modifier, shielded: Boolean = false, locked: Boolean = false, @@ -286,10 +345,12 @@ fun App( .fillMaxSize() .windowInsetsPadding(WindowInsets.statusBars), ) { - AppNavHost( - navController = navController, - startDestination = startDestination, - ) + CompositionLocalProvider(LocalEraseEpoch provides eraseEpoch) { + AppNavHost( + navController = navController, + startDestination = startDestination, + ) + } } } } diff --git a/app/src/main/java/com/pasich/encly/core/security/SecurityManager.kt b/app/src/main/java/com/pasich/encly/core/security/SecurityManager.kt index e73b688..c57573f 100644 --- a/app/src/main/java/com/pasich/encly/core/security/SecurityManager.kt +++ b/app/src/main/java/com/pasich/encly/core/security/SecurityManager.kt @@ -5,6 +5,8 @@ import androidx.fragment.app.FragmentActivity import com.pasich.encly.core.AppLogger import com.pasich.encly.data.backup.BackupManager import com.pasich.encly.data.database.SecureDatabaseManager +import java.security.SecureRandom +import java.util.concurrent.atomic.AtomicLong import javax.inject.Inject import javax.inject.Singleton @@ -69,10 +71,10 @@ class SecurityManager @Inject constructor( @Volatile private var sessionUnlockedWithRecovery = false - // True while the open session is the empty vault a wipe-PIN unlock just made. Nothing of - // the erased vault (such as the note that was open when the app re-locked) may reappear. - @Volatile - private var sessionIsErasedVault = false + // Changes whenever a wipe-PIN erase replaces the database, and never on lock or unlock, so a + // screen remembered before an erase (the note open when the app re-locked) is not shown in + // the empty vault. Random per process: one saved by an earlier process never matches. + private val eraseEpoch = AtomicLong(SecureRandom().nextLong()) var securityStatus = InitialStatus.NO @@ -163,8 +165,9 @@ class SecurityManager @Inject constructor( /** * One PIN attempt from the lock screen; [pin] is wiped. The wipe PIN reports - * [VaultUnlockResult.SUCCESS] like the PIN, with the new, empty vault open (see - * [openErasedVault]). + * [VaultUnlockResult.SUCCESS] like the PIN, with the new, empty vault open: phases 1 and 2 + * of the erase ran in [AuthenticationManager.unlockWithPin], and [unlockWithRawKey] replaces + * the old database with an empty one. */ fun unlockWithPin(pin: CharArray): VaultUnlockResult { val attempt = try { @@ -180,7 +183,7 @@ class SecurityManager @Inject constructor( } is PinUnlock.Erased -> try { - if (openErasedVault(attempt.dek)) VaultUnlockResult.SUCCESS else VaultUnlockResult.DB_ERROR + if (unlockWithRawKey(attempt.dek)) VaultUnlockResult.SUCCESS else VaultUnlockResult.DB_ERROR } finally { SensitiveDataCleaner.clear(attempt.dek) } @@ -317,7 +320,9 @@ class SecurityManager @Inject constructor( * * While a wipe-PIN erase still has to create the empty database ([WipeStage.DATABASE]), * whatever is left of the old one is deleted first and the new one is created with [dek]: - * only the new PIN slot can still produce a DEK then. + * only the new PIN slot can still produce a DEK then. Phase 3 of the erase: the erased slots + * are already gone, so a kill from here on ends in an empty vault, which the next start or + * unlock finishes. */ fun unlockWithRawKey(dek: ByteArray, allowCreate: Boolean = false): Boolean { if (dek.size != DEK_LENGTH) return false @@ -327,6 +332,7 @@ class SecurityManager @Inject constructor( if (ok) { if (replaceDatabase) { authenticationManager.markWipeDatabaseCreated() + eraseEpoch.incrementAndGet() completePendingWipe() } setSessionKey(dek) @@ -335,18 +341,6 @@ class SecurityManager @Inject constructor( return ok } - /** - * Phase 3 of a wipe-PIN erase, on the lock screen: [unlockWithRawKey] replaces the old - * database with an empty one opened with [dek] and cleans up the rest. The erased slots are - * already gone (phases 1 and 2, in [AuthenticationManager.unlockWithPin]), so a kill from - * here on ends in an empty vault, which the next start or unlock finishes. - */ - private fun openErasedVault(dek: ByteArray): Boolean { - val opened = unlockWithRawKey(dek) - if (opened) sessionIsErasedVault = true - return opened - } - /** * Finishes a wipe-PIN erase (see [WipeStage]); does nothing when none is pending. Safe to * repeat: every step is a deletion. Runs at startup and once the empty database is open. @@ -365,8 +359,11 @@ class SecurityManager @Inject constructor( if (stage == WipeStage.CLEANUP) authenticationManager.clearPendingWipe() } - /** Whether the open session is the empty vault a wipe-PIN unlock just made. */ - fun isErasedVaultSession(): Boolean = sessionIsErasedVault && sessionDek != null + /** + * Changes whenever a wipe-PIN erase replaces the database, never on lock or unlock: what was + * remembered under another value belongs to a vault that is gone. + */ + fun eraseEpoch(): Long = eraseEpoch.get() // --- wipe PIN ----------------------------------------------------------------------- @@ -478,7 +475,6 @@ class SecurityManager @Inject constructor( sessionDek?.let(SensitiveDataCleaner::clear) sessionDek = null sessionUnlockedWithRecovery = false - sessionIsErasedVault = false } fun wipeAndReset() { diff --git a/app/src/main/java/com/pasich/encly/core/security/SessionLockManager.kt b/app/src/main/java/com/pasich/encly/core/security/SessionLockManager.kt index 1953454..715e6fe 100644 --- a/app/src/main/java/com/pasich/encly/core/security/SessionLockManager.kt +++ b/app/src/main/java/com/pasich/encly/core/security/SessionLockManager.kt @@ -58,6 +58,15 @@ class SessionLockManager @Inject constructor( /** Emits true when the app was re-locked in the background and must show the lock screen. */ override val locked: StateFlow = _locked.asStateFlow() + /** + * Counts published unlocks: each one starts a new session. A screen that was stopped while + * this changed (the vault was unlocked on another lock screen, such as the My Notes + * hand-off's) shows a session it never saw, possibly after an erase. + */ + @Volatile + var sessionGeneration = 0L + private set + /** * Whether the process is in the foreground (between ProcessLifecycleOwner ON_START and * ON_STOP). An unlock that finishes after ON_STOP must not leave the vault open. @@ -197,6 +206,7 @@ class SessionLockManager @Inject constructor( relock() return false } + sessionGeneration++ _locked.value = false return true } diff --git a/app/src/main/java/com/pasich/encly/presentation/navigation/NavHost.kt b/app/src/main/java/com/pasich/encly/presentation/navigation/NavHost.kt index bdd461f..626c070 100644 --- a/app/src/main/java/com/pasich/encly/presentation/navigation/NavHost.kt +++ b/app/src/main/java/com/pasich/encly/presentation/navigation/NavHost.kt @@ -165,7 +165,9 @@ fun AppNavHost(navController: NavHostController, startDestination: String = NavR /** * The note editor. Records the id of its note on [entry] (also the id a new note gets on its - * first save), so a background re-lock can return to it (see [RelockReturn]). + * first save), so a background re-lock can return to it (see [RelockReturn]), and the vault's + * erase epoch ([LocalEraseEpoch]) when it first showed it: the note is reopened only in that + * vault. */ @Composable private fun EditNoteDestination( @@ -174,8 +176,14 @@ private fun EditNoteDestination( viewModel: EditNoteViewModel = hiltViewModel(), ) { val noteState by viewModel.state.collectAsState() - LaunchedEffect(entry, noteState.note.id) { - entry.savedStateHandle[RelockReturn.OPEN_NOTE_ID] = noteState.note.id + val eraseEpoch = LocalEraseEpoch.current + LaunchedEffect(entry, noteState.note.id, eraseEpoch) { + val state = entry.savedStateHandle + state[RelockReturn.OPEN_NOTE_ID] = noteState.note.id + // Kept from the first time: an erase while the editor stays open does not adopt it. + if (eraseEpoch != null && !state.contains(RelockReturn.OPEN_NOTE_EPOCH)) { + state[RelockReturn.OPEN_NOTE_EPOCH] = eraseEpoch() + } } // The screen resolves the same ViewModel instance: it is scoped to [entry]. EditNoteScreen(navController) diff --git a/app/src/main/java/com/pasich/encly/presentation/navigation/RelockReturn.kt b/app/src/main/java/com/pasich/encly/presentation/navigation/RelockReturn.kt index b2002e1..ba88ffa 100644 --- a/app/src/main/java/com/pasich/encly/presentation/navigation/RelockReturn.kt +++ b/app/src/main/java/com/pasich/encly/presentation/navigation/RelockReturn.kt @@ -1,16 +1,29 @@ package com.pasich.encly.presentation.navigation +import androidx.compose.runtime.staticCompositionLocalOf + +/** + * SecurityManager.eraseEpoch, for the editor to record with its note (see [RelockReturn]); + * MainActivity provides it. Without it nothing is recorded, so no note is reopened. + */ +val LocalEraseEpoch = staticCompositionLocalOf<(() -> Long)?> { null } + /** * Brings the user back to the note that was open when the app re-locked in the background. * * The editor records its note's id on its own back-stack entry ([OPEN_NOTE_ID]), including the - * id a new note gets on its first save. When the session re-locks, MainActivity turns that - * into a route ([routeFor]) and leaves it on the lock screen's entry ([RETURN_ROUTE]); a PIN - * or fingerprint unlock opens it again over Home. Only the id travels, never note content. + * id a new note gets on its first save, and the erase epoch of the vault it showed it in + * ([OPEN_NOTE_EPOCH], see SecurityManager.eraseEpoch). When the session re-locks, MainActivity + * turns that into a route ([routeFor]) and leaves it on the lock screen's entry + * ([RETURN_ROUTE], [RETURN_EPOCH]); a PIN or fingerprint unlock opens it again over Home, unless + * a wipe-PIN erase changed the epoch since (or the process was restarted, which starts another + * epoch). Only the id travels, never note content. */ object RelockReturn { const val OPEN_NOTE_ID = "relock_open_note_id" + const val OPEN_NOTE_EPOCH = "relock_open_note_epoch" const val RETURN_ROUTE = "relock_return_route" + const val RETURN_EPOCH = "relock_return_epoch" /** * The route that reopens the note shown by the destination [destinationRoute], or null when @@ -23,3 +36,32 @@ object RelockReturn { return if (inEditor && id != null) "${NavRoutes.EditNoteRoute.name}/$id$params" else null } } + +/** What MainActivity does when it comes back to the foreground (see [afterStop]). */ +enum class SessionResume { + /** Nothing changed that it did not see: its screens are current. */ + KEEP, + + /** The vault was unlocked elsewhere and is closed again: the lock screen, remembering nothing. */ + LOCK_SCREEN, + + /** The vault was unlocked elsewhere and is open: a fresh Home, none of the old screens. */ + HOME, + ; + + companion object { + /** + * [stoppedAt] is the session generation (SessionLockManager.sessionGeneration) when the + * activity stopped, null when it has not stopped yet. An unlock published meanwhile came + * from another lock screen (the My Notes hand-off): the screens kept here belong to a + * session that ended (their ViewModels dropped their content), possibly in a vault that + * was erased since, so none of them is shown again, nor reopened after the next unlock. + * A re-lock alone is handled where it is observed, keeping the note to return to. + */ + fun afterStop(stoppedAt: Long?, generation: Long, locked: Boolean): SessionResume = when { + stoppedAt == null || stoppedAt == generation -> KEEP + locked -> LOCK_SCREEN + else -> HOME + } + } +} diff --git a/app/src/main/java/com/pasich/encly/presentation/screen/LockScreen.kt b/app/src/main/java/com/pasich/encly/presentation/screen/LockScreen.kt index 7fa9f55..8983271 100644 --- a/app/src/main/java/com/pasich/encly/presentation/screen/LockScreen.kt +++ b/app/src/main/java/com/pasich/encly/presentation/screen/LockScreen.kt @@ -158,20 +158,23 @@ private class PinAuth( * MainActivity), once the reveal covers the window so Home composes out of sight. If the * session closed again while the reveal played (e.g. the app went to the background), it stays * on the lock screen rather than open Home over a locked vault. The note is not reopened when - * [canReopenNote] says the open vault is not the one it was in (a wipe-PIN unlock). + * [canReopenNote] says the open vault is not the one it was in (a wipe-PIN erase since). */ private fun NavHostController.leaveLockScreen( reveal: UnlockRevealState?, isSessionLocked: () -> Boolean, - canReopenNote: () -> Boolean, + canReopenNote: (savedEpoch: Long?) -> Boolean, ) { - val returnRoute = currentBackStackEntry?.savedStateHandle?.get(RelockReturn.RETURN_ROUTE) + val lockState = currentBackStackEntry?.savedStateHandle + val returnRoute = lockState?.get(RelockReturn.RETURN_ROUTE) + val returnEpoch = lockState?.get(RelockReturn.RETURN_EPOCH) reveal.revealThen { if (!isSessionLocked()) { navigate(NavRoutes.HomeRoute.name) { popUpTo(NavRoutes.LockRoute.name) { inclusive = true } } - if (returnRoute != null && canReopenNote()) navigate(returnRoute) + // Checked after the unlock: an erase is what changes the epoch. + if (returnRoute != null && canReopenNote(returnEpoch)) navigate(returnRoute) } } } diff --git a/app/src/main/java/com/pasich/encly/presentation/viewmodel/LockViewModel.kt b/app/src/main/java/com/pasich/encly/presentation/viewmodel/LockViewModel.kt index e5af910..be78e3b 100644 --- a/app/src/main/java/com/pasich/encly/presentation/viewmodel/LockViewModel.kt +++ b/app/src/main/java/com/pasich/encly/presentation/viewmodel/LockViewModel.kt @@ -59,10 +59,11 @@ class LockViewModel @Inject constructor( fun isSessionLocked(): Boolean = sessionLockManager.locked.value /** - * Whether the note open before the re-lock may be reopened: not in the empty vault a - * wipe-PIN unlock made, where it does not exist (an empty editor would give the erase away). + * Whether the note remembered at the re-lock under [savedEpoch] may be reopened: only in the + * vault it was in. After a wipe-PIN erase (here or on another lock screen) it does not + * exist, and an empty editor would give the erase away. */ - fun canReopenNote(): Boolean = !securityManager.isErasedVaultSession() + fun canReopenNote(savedEpoch: Long?): Boolean = savedEpoch != null && savedEpoch == securityManager.eraseEpoch() /** Unlocks with [pin], which is wiped. */ fun authenticatePin(pin: CharArray, onResult: (PinUnlockResult) -> Unit) { diff --git a/app/src/test/java/com/pasich/encly/core/security/SessionLockManagerTest.kt b/app/src/test/java/com/pasich/encly/core/security/SessionLockManagerTest.kt index 5acfba8..ec6291e 100644 --- a/app/src/test/java/com/pasich/encly/core/security/SessionLockManagerTest.kt +++ b/app/src/test/java/com/pasich/encly/core/security/SessionLockManagerTest.kt @@ -1,6 +1,7 @@ package com.pasich.encly.core.security import androidx.lifecycle.LifecycleOwner +import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNull import org.junit.Assert.assertTrue @@ -75,6 +76,22 @@ class SessionLockManagerTest { assertFalse(manager.locked.value) } + @Test + fun eachPublishedUnlockStartsANewSessionGenerationAndALockDoesNot() { + `when`(security.isDatabaseUnlocked()).thenReturn(true) + val first = manager.sessionGeneration + + manager.lockNow() + assertEquals(first, manager.sessionGeneration) + assertTrue(manager.onUnlocked()) + assertEquals(first + 1, manager.sessionGeneration) + + // An unlock that lands in the background is closed again, never published. + manager.onStop(owner) + assertFalse(manager.onUnlocked()) + assertEquals(first + 1, manager.sessionGeneration) + } + @Test fun unlockInTheForegroundIsPublished() { `when`(security.isDatabaseUnlocked()).thenReturn(true) diff --git a/app/src/test/java/com/pasich/encly/core/security/WipePinTest.kt b/app/src/test/java/com/pasich/encly/core/security/WipePinTest.kt index 1512d0b..cae1199 100644 --- a/app/src/test/java/com/pasich/encly/core/security/WipePinTest.kt +++ b/app/src/test/java/com/pasich/encly/core/security/WipePinTest.kt @@ -11,6 +11,7 @@ import com.pasich.encly.testutil.tempVaultStore import org.junit.Assert.assertArrayEquals import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull import org.junit.Assert.assertTrue @@ -399,6 +400,7 @@ class WipePinTest { fun theWipePinOnTheLockScreenOpensAnEmptyVault() { val vault = Vault() `when`(vault.database.unlockDatabase(anyByteArray(), anyBoolean())).thenReturn(true) + val epoch = vault.security.eraseEpoch() assertEquals(VaultUnlockResult.SUCCESS, vault.security.unlockWithPin(pin(WIPE_PIN))) @@ -409,13 +411,15 @@ class WipePinTest { assertNull(vault.auth.pendingWipe()) assertFalse(vault.factor.hasKey(PinKeySlot.A)) assertFalse(vault.prefs.contains(BackupManager.LAST_EXPORT_KEY)) - assertTrue(vault.security.isErasedVaultSession()) + assertNotEquals(epoch, vault.security.eraseEpoch()) // Onboarding stays committed: a restart asks for the PIN of the empty vault. `when`(vault.database.hasEncryptedDatabase()).thenReturn(true) assertEquals(InitialStatus.AUTH, vault.restart().resolveInitialStatus()) + // Locking does not bring the old vault's epoch back. + val erased = vault.security.eraseEpoch() vault.security.lock() - assertFalse(vault.security.isErasedVaultSession()) + assertEquals(erased, vault.security.eraseEpoch()) } @Test @@ -455,14 +459,30 @@ class WipePinTest { fun aNormalUnlockIsNotAnErasedVaultSession() { val vault = Vault() `when`(vault.database.unlockDatabase(anyByteArray(), anyBoolean())).thenReturn(true) + val epoch = vault.security.eraseEpoch() assertEquals(VaultUnlockResult.SUCCESS, vault.security.unlockWithPin(pin(PIN))) verify(vault.database, never()).wipe() - assertFalse(vault.security.isErasedVaultSession()) + assertEquals(epoch, vault.security.eraseEpoch()) assertTrue(vault.prefs.contains(BackupManager.LAST_EXPORT_KEY)) } + @Test + fun anUnlockThatOnlyThenCreatesTheErasedDatabaseAlsoChangesTheEpoch() { + // The wipe PIN's own open failed; the retry is a plain PIN unlock of the new slot. + val vault = Vault() + `when`(vault.database.unlockDatabase(anyByteArray(), anyBoolean())).thenReturn(false) + assertEquals(VaultUnlockResult.DB_ERROR, vault.security.unlockWithPin(pin(WIPE_PIN))) + val epoch = vault.security.eraseEpoch() + `when`(vault.database.unlockDatabase(anyByteArray(), anyBoolean())).thenReturn(true) + + assertEquals(VaultUnlockResult.SUCCESS, vault.security.unlockWithPin(pin(WIPE_PIN))) + + assertNotEquals(epoch, vault.security.eraseEpoch()) + assertNull(vault.auth.pendingWipe()) + } + @Test fun startupAddsTheDecoyToAnExistingVault() { val vault = Vault(withWipePin = false) diff --git a/app/src/test/java/com/pasich/encly/presentation/navigation/RelockReturnTest.kt b/app/src/test/java/com/pasich/encly/presentation/navigation/RelockReturnTest.kt index e893b65..e19caa3 100644 --- a/app/src/test/java/com/pasich/encly/presentation/navigation/RelockReturnTest.kt +++ b/app/src/test/java/com/pasich/encly/presentation/navigation/RelockReturnTest.kt @@ -28,4 +28,18 @@ class RelockReturnTest { assertNull(RelockReturn.routeFor("HomeRoute", openNoteId = 42L, readOnly = false)) assertNull(RelockReturn.routeFor(null, openNoteId = 42L, readOnly = false)) } + + @Test + fun aStoppedScreenIsKeptWhenNoUnlockHappenedElsewhere() { + assertEquals(SessionResume.KEEP, SessionResume.afterStop(stoppedAt = null, generation = 3, locked = true)) + // A re-lock alone keeps the screens: the lock screen remembers the open note itself. + assertEquals(SessionResume.KEEP, SessionResume.afterStop(stoppedAt = 3, generation = 3, locked = true)) + assertEquals(SessionResume.KEEP, SessionResume.afterStop(stoppedAt = 3, generation = 3, locked = false)) + } + + @Test + fun anUnlockElsewhereWhileStoppedDropsTheOldScreens() { + assertEquals(SessionResume.LOCK_SCREEN, SessionResume.afterStop(stoppedAt = 3, generation = 4, locked = true)) + assertEquals(SessionResume.HOME, SessionResume.afterStop(stoppedAt = 3, generation = 5, locked = false)) + } } diff --git a/app/src/test/java/com/pasich/encly/presentation/viewmodel/LockViewModelTest.kt b/app/src/test/java/com/pasich/encly/presentation/viewmodel/LockViewModelTest.kt index b533d86..e58273b 100644 --- a/app/src/test/java/com/pasich/encly/presentation/viewmodel/LockViewModelTest.kt +++ b/app/src/test/java/com/pasich/encly/presentation/viewmodel/LockViewModelTest.kt @@ -74,6 +74,15 @@ class LockViewModelTest { assertFalse(sessionLock.locked.value) } + @Test + fun aNoteIsReopenedOnlyInTheVaultItWasRememberedIn() { + `when`(security.eraseEpoch()).thenReturn(7L) + + assertTrue(viewModel.canReopenNote(7L)) + assertFalse("an erase changed the epoch", viewModel.canReopenNote(6L)) + assertFalse("nothing remembered", viewModel.canReopenNote(null)) + } + @Test fun aWrongPinAndABrokenVaultAreToldApart() = runTest { `when`(security.unlockWithPin(PIN.toCharArray())).thenReturn(VaultUnlockResult.INVALID_CREDENTIAL) diff --git a/config/detekt/detekt.yml b/config/detekt/detekt.yml index 9c25c3f..0585a45 100644 --- a/config/detekt/detekt.yml +++ b/config/detekt/detekt.yml @@ -53,6 +53,8 @@ Compose: # The unlock reveal, provided once by App (presentation/effects/UnlockReveal.kt). - LocalUnlockReveal - LocalSpacing + # The vault's erase epoch, provided once by App for the note editor (presentation/navigation/RelockReturn.kt). + - LocalEraseEpoch CompositionLocalNaming: active: true ContentEmitterReturningValues: diff --git a/docs/architecture.md b/docs/architecture.md index 3ff3c99..7b3fd80 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -55,8 +55,12 @@ fresh unlock starts with new database-backed ViewModels. Until the lock screen i visible destination, an opaque shield covers the previous screen, so the first frame after returning never shows plaintext. An unlock that completes after the app left the foreground is closed again at once. Open task and tag editors save on pause, like the note editor, because the -re-lock discards them. The note that was open when the app re-locked is recorded (`RelockReturn`) and -opened again after the unlock, with fresh ViewModels. Pages move on Material's shared X axis; +re-lock discards them. The note that was open when the app re-locked is recorded (`RelockReturn`), +with the erase epoch of the vault it was opened in, and opened again after the unlock, with fresh +ViewModels, only while that epoch is current (a wipe-PIN erase or a new process changes it). A +screen that was in the background while the vault was unlocked on another lock screen (the My +Notes hand-off) is replaced by the lock screen or Home when it comes back (`SessionResume`). +Pages move on Material's shared X axis; the full unlock reveal plays only on the first unlock after launch, later unlocks cross-fade. ## Encrypted backups From 924fc4e6331b044d11f42c03f1620fadc39b2035 Mon Sep 17 00:00:00 2001 From: pasichdev Date: Mon, 5 Oct 2026 12:38:36 +0300 Subject: [PATCH 11/19] fix(security): make the wipe-PIN erase safe against races and failed writes Several gaps around the erase's last steps: - A recreated activity (a rotation while the erase ran) resolves the startup state again and finished the pending erase at the same time as the unlock was creating the empty database: the open-check passed before the database existed, and the delete then removed the new one. Creating, opening and deleting the database during an erase are now serialized under one lock, the startup delete only happens while the database is closed (checked under the database manager's own lock), and the startup finish runs once per process. - Recording that the empty database exists was not checked. If that store write failed, the next start took the new vault for the old one and deleted it with everything added since. The unlock now closes the database again and fails; the next one creates it anew. - An erase that failed after making the new Keystore key (deriving with it, or the store write) left that second key behind while the wipe PIN stayed set. It is deleted again, and a key under the second alias left by a process that died before the erase was recorded is deleted at the next start (only while the first alias is active and nothing is pending). - The wipe PIN's unlock waited for deleting the old PIN key, the biometric key and the export date. Those now run once the vault is shown. The remaining difference to a normal unlock (a new Keystore key, two synced store writes, creating the database) is stated in SECURITY.md, as is the downgrade case where an older build's PIN change silently turns the wipe PIN off. Tests cover the interleaving with a blocked database open, the failed record, both failed erases, the stray key and the deferred cleanup. Refs: #52 --- SECURITY.md | 36 +++-- .../core/security/AuthenticationManager.kt | 22 ++- .../encly/core/security/SecurityManager.kt | 81 +++++++--- .../data/database/SecureDatabaseManager.kt | 12 ++ .../presentation/viewmodel/LockViewModel.kt | 6 + .../pasich/encly/core/security/WipePinTest.kt | 151 +++++++++++++++++- .../viewmodel/LockViewModelTest.kt | 15 +- .../pasich/encly/testutil/SecurityFakes.kt | 11 +- .../com/pasich/encly/ui/screens/TestApp.kt | 2 +- 9 files changed, 289 insertions(+), 47 deletions(-) diff --git a/SECURITY.md b/SECURITY.md index 8e82b4e..914e3ab 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -87,14 +87,21 @@ and show me"), not against a forensic examination. 2. one atomic store write swaps them in and drops the recovery, backup-key and biometric slots, the biometric flag and the lockout, and records `wipe.pending`; 3. the old `database.db` (with `-wal`, `-shm`) is deleted, the empty database is created with - the new DEK and opened, then the old PIN key, the biometric key and the "last export" date + the new DEK and opened, and `wipe.pending` records that it exists before the vault is shown + (if that write fails, the database is closed again and the unlock fails; the next one + creates it anew); + 4. while the vault is revealed, the old PIN key, the biometric key and the "last export" date are deleted and `wipe.pending` is dropped. A kill at any point ends in either the old vault (before step 2) or an empty one: startup - repeats step 3 while `wipe.pending` is set (every step is a deletion), and the next PIN unlock - creates the empty database if it does not exist yet. If no key can be created under the other - alias, the erase still replaces the slots and deletes the database, only without erasing the - old PIN key. Afterwards the old PIN is a wrong PIN like any other, and the wipe PIN is the PIN + repeats steps 3 and 4 while `wipe.pending` is set (every step is a deletion; the database is + only deleted while it is not open, under the same lock as the unlock that creates it, and only + at the first start of a process), and the next PIN unlock creates the empty database if it does + not exist yet. If no key can be created under the other alias, the erase still replaces the + slots and deletes the database, only without erasing the old PIN key. An erase that fails + before step 2 changes nothing and deletes the key it made; one the process died in before + step 2 leaves that key under the second alias, which the next start deletes (while the first + alias is active and nothing is pending). Afterwards the old PIN is a wrong PIN like any other, and the wipe PIN is the PIN of the empty vault. Theme, sorting, auto-lock and keyboard settings stay. `vault.version` stays so that the empty vault is a committed vault; it has no recovery phrase until one is added. @@ -109,9 +116,13 @@ and show me"), not against a forensic examination. new epoch, and a restarted process starts one that matches nothing), and a screen that was in the background while the vault was unlocked elsewhere (the My Notes hand-off's lock screen) is replaced by the lock screen or the notes list when it comes back. -- **Unlock time.** The wipe path adds a Keystore key generation, one HMAC, a store write and - creating the empty database to the same KDF run; the empty vault opens behind the same - unlock animation. +- **Unlock time.** The wipe path is slower than a normal unlock, and this is not hidden: on top + of the same KDF run it generates a new Keystore key (in StrongBox where there is one, which + can take noticeably longer than the TEE), runs one more HMAC, writes the store twice (each + write synced to disk) and creates the empty database. The rest of the erase (deleting the old + keys) runs after the vault is shown, and the empty vault opens behind the same unlock + animation, but someone timing the unlock, or simply noticing a longer pause on a phone with + StrongBox, may tell the two apart. No delay is added to normal unlocks to hide it. - **Limits.** Against a forensic look at the phone this is partial: leftovers in flash, the filesystem journal, two snapshots taken before and after, or simply an empty vault on a phone where Encly was used for years. On a rooted, unlocked phone PIN guessing through the Keystore @@ -119,9 +130,12 @@ and show me"), not against a forensic examination. reliable, which is why the erase relies on deleting keys first. - **Downgrade.** Older builds ignore the new store entries and keep opening a vault that never used the wipe PIN. After a wipe has happened (the PIN key moved to the second alias), an older - build only knows the first alias: the PIN reports a lost key there and cannot unlock. Apart - from this, the same release moves the database to version 4, which older builds cannot open at - all (see CHANGELOG). + build only knows the first alias: the PIN reports a lost key there and cannot unlock. A PIN + changed in an older build gets a new salt, which silently turns a wipe PIN off: after upgrading + again the wipe slot is kept but can never open, and Settings does not say so (it cannot tell + without the wipe PIN), so set the wipe PIN again after such a round trip. Apart from this, the + same release moves the database to version 4, which older builds cannot open at all (see + CHANGELOG). ### Biometric slot diff --git a/app/src/main/java/com/pasich/encly/core/security/AuthenticationManager.kt b/app/src/main/java/com/pasich/encly/core/security/AuthenticationManager.kt index f5d3bb4..67de1cb 100644 --- a/app/src/main/java/com/pasich/encly/core/security/AuthenticationManager.kt +++ b/app/src/main/java/com/pasich/encly/core/security/AuthenticationManager.kt @@ -306,7 +306,17 @@ class AuthenticationManager @Inject constructor( fun markWipeDatabaseCreated(): Boolean = store.edit { putInt(WIPE_PENDING_KEY, WipeStage.CLEANUP.ordinal) } /** Deletes the PIN key the erase moved away from (nothing when there is none). */ - fun deleteRetiredPinKey() = factor.delete(activeKeySlot().other) + fun deleteRetiredPinKey() = synchronized(attemptLock) { factor.delete(activeKeySlot().other) } + + /** + * Startup: deletes a key in slot B that no committed state uses, left by a wipe-PIN erase + * the process died in between phase 1 (the new key) and phase 2 (the store edit). Only with + * slot A active and no erase pending; slot A itself is never touched here, it is the one key + * older builds know. Under the attempt lock, so it never races an erase in progress. + */ + fun deleteStrayPinKey() = synchronized(attemptLock) { + if (pendingWipe() == null && activeKeySlot() == PinKeySlot.A) factor.delete(PinKeySlot.B) + } /** The wipe-PIN erase is complete. */ fun clearPendingWipe(): Boolean = store.edit { remove(WIPE_PENDING_KEY) } @@ -404,10 +414,18 @@ class AuthenticationManager @Inject constructor( * Phases 1 and 2 of the wipe-PIN erase (see [unlockWithPin]). If no key can be made in the * other Keystore slot, the new PIN slot uses the current key: the old slots are still * dropped and the old database still deleted, only the crypto-erase of the old PIN key is - * lost. A store that cannot be written changes nothing and reports [PinUnlock.Failed]. + * lost. A store that cannot be written changes nothing and reports [PinUnlock.Failed]; the + * key made for it is deleted again, so a failed erase leaves no extra Keystore key behind. */ private fun eraseVault(pin: CharArray, salt: ByteArray, keys: PinKeys): PinUnlock { val keySlot = freshKeySlot(keys.keySlot) + val result = sealErasedVault(pin, salt, keys, keySlot) + if (result !is PinUnlock.Erased && keySlot != keys.keySlot) factor.delete(keySlot) + return result + } + + /** Phases 1 and 2 with the new key in [keySlot]; see [eraseVault]. */ + private fun sealErasedVault(pin: CharArray, salt: ByteArray, keys: PinKeys, keySlot: PinKeySlot): PinUnlock { val kek = try { val hardware = hardwareHalf(pin, salt, keySlot) try { diff --git a/app/src/main/java/com/pasich/encly/core/security/SecurityManager.kt b/app/src/main/java/com/pasich/encly/core/security/SecurityManager.kt index c57573f..c8aa3d4 100644 --- a/app/src/main/java/com/pasich/encly/core/security/SecurityManager.kt +++ b/app/src/main/java/com/pasich/encly/core/security/SecurityManager.kt @@ -6,6 +6,7 @@ import com.pasich.encly.core.AppLogger import com.pasich.encly.data.backup.BackupManager import com.pasich.encly.data.database.SecureDatabaseManager import java.security.SecureRandom +import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicLong import javax.inject.Inject import javax.inject.Singleton @@ -71,6 +72,15 @@ class SecurityManager @Inject constructor( @Volatile private var sessionUnlockedWithRecovery = false + // Serializes what creates, opens or deletes the database file while a wipe-PIN erase is + // pending: an unlock that replaces the database, and the erase's finish at startup (which a + // recreated activity runs again while an unlock may be in the middle of creating the new one). + private val databaseLock = Any() + + // The erase's finish at startup runs once per process: later calls (a rotated activity, the + // My Notes hand-off) find the vault already in use. + private val startupFinished = AtomicBoolean(false) + // Changes whenever a wipe-PIN erase replaces the database, and never on lock or unlock, so a // screen remembered before an erase (the note open when the app re-locked) is not shown in // the empty vault. Random per process: one saved by an earlier process never matches. @@ -99,8 +109,12 @@ class SecurityManager @Inject constructor( if (!isOnboardingShown()) return uncommittedVaultStatus() // Vaults from before the wipe PIN get their decoy wipe slot; no PIN needed, nothing shown. authenticationManager.ensureWipeSlot() - // A wipe-PIN erase the process died in is finished before anything else looks at the vault. - completePendingWipe() + if (startupFinished.compareAndSet(false, true)) { + // A key left by an erase the process died in before it was recorded. + authenticationManager.deleteStrayPinKey() + // A wipe-PIN erase the process died in is finished before anything else looks at the vault. + completePendingWipe() + } if (!seedPhraseManager.verificationKeyData()) return InitialStatus.LOSS_CRYPTO // An erase that has not created the empty database yet does so at the next PIN unlock. if (!secureDatabaseManager.hasEncryptedDatabase() && authenticationManager.pendingWipe() == null) { @@ -167,7 +181,9 @@ class SecurityManager @Inject constructor( * One PIN attempt from the lock screen; [pin] is wiped. The wipe PIN reports * [VaultUnlockResult.SUCCESS] like the PIN, with the new, empty vault open: phases 1 and 2 * of the erase ran in [AuthenticationManager.unlockWithPin], and [unlockWithRawKey] replaces - * the old database with an empty one. + * the old database with an empty one. The rest of the erase is left for + * [completePendingWipe], which the caller runs once the vault is shown (or the next start + * does), so the wipe PIN does not wait for it. */ fun unlockWithPin(pin: CharArray): VaultUnlockResult { val attempt = try { @@ -326,37 +342,52 @@ class SecurityManager @Inject constructor( */ fun unlockWithRawKey(dek: ByteArray, allowCreate: Boolean = false): Boolean { if (dek.size != DEK_LENGTH) return false - val replaceDatabase = authenticationManager.pendingWipe() == WipeStage.DATABASE - if (replaceDatabase) secureDatabaseManager.wipe() - val ok = secureDatabaseManager.unlockDatabase(dek, allowCreate = allowCreate || replaceDatabase) - if (ok) { - if (replaceDatabase) { - authenticationManager.markWipeDatabaseCreated() - eraseEpoch.incrementAndGet() - completePendingWipe() + return synchronized(databaseLock) { + val replaceDatabase = authenticationManager.pendingWipe() == WipeStage.DATABASE + if (replaceDatabase) secureDatabaseManager.wipe() + val opened = secureDatabaseManager.unlockDatabase(dek, allowCreate = allowCreate || replaceDatabase) + val ok = opened && (!replaceDatabase || recordErasedDatabase()) + if (ok) { + setSessionKey(dek) + securityStatus = InitialStatus.MAIN } - setSessionKey(dek) - securityStatus = InitialStatus.MAIN + ok + } + } + + /** + * The empty database of an erase was created: records it before anything can be written to + * it. Unrecorded, the next start would take it for the old database and delete it, with + * everything added since; so when the store cannot be written the database is closed again + * and the unlock fails, and the next one creates it anew. + */ + private fun recordErasedDatabase(): Boolean { + if (!authenticationManager.markWipeDatabaseCreated()) { + AppLogger.w(TAG, "The erased vault's new database could not be recorded") + secureDatabaseManager.reset() + return false } - return ok + eraseEpoch.incrementAndGet() + return true } /** * Finishes a wipe-PIN erase (see [WipeStage]); does nothing when none is pending. Safe to - * repeat: every step is a deletion. Runs at startup and once the empty database is open. - * Theme, sorting and auto-lock settings are kept, as on any vault. + * repeat: every step is a deletion. Runs at startup, and after an unlock that opened the + * empty database (off the unlock's path, see [unlockWithPin]). Theme, sorting and + * auto-lock settings are kept, as on any vault. */ fun completePendingWipe() { - val stage = authenticationManager.pendingWipe() ?: return - // Before the empty database exists, whatever is left of the old one goes (never an open one). - if (stage == WipeStage.DATABASE && !secureDatabaseManager.isDatabaseUnlocked()) { - secureDatabaseManager.wipe() + synchronized(databaseLock) { + val stage = authenticationManager.pendingWipe() ?: return + // Before the empty database exists, whatever is left of the old one goes (never an open one). + if (stage == WipeStage.DATABASE) secureDatabaseManager.wipeIfClosed() + authenticationManager.deleteRetiredPinKey() + // Its slot went with the erase; this deletes the Keystore key behind it. + biometricManager.disable() + appFlags.edit().remove(BackupManager.LAST_EXPORT_KEY).commit() + if (stage == WipeStage.CLEANUP) authenticationManager.clearPendingWipe() } - authenticationManager.deleteRetiredPinKey() - // Its slot went with the erase; this deletes the Keystore key behind it. - biometricManager.disable() - appFlags.edit().remove(BackupManager.LAST_EXPORT_KEY).commit() - if (stage == WipeStage.CLEANUP) authenticationManager.clearPendingWipe() } /** diff --git a/app/src/main/java/com/pasich/encly/data/database/SecureDatabaseManager.kt b/app/src/main/java/com/pasich/encly/data/database/SecureDatabaseManager.kt index 556c26f..cc2c4fc 100644 --- a/app/src/main/java/com/pasich/encly/data/database/SecureDatabaseManager.kt +++ b/app/src/main/java/com/pasich/encly/data/database/SecureDatabaseManager.kt @@ -131,6 +131,18 @@ class SecureDatabaseManager @Inject constructor(@param:ApplicationContext privat deleteDatabaseFiles() } + /** + * Deletes the database files unless the database is open, checked and done under the same + * lock [unlockDatabase] holds, so a database being opened (or just created) meanwhile is + * never deleted. True when they were deleted. + */ + @Synchronized + fun wipeIfClosed(): Boolean { + if (isUnlocked) return false + deleteDatabaseFiles() + return true + } + companion object { private const val TAG = "SecureDatabaseManager" private const val DB_NAME = "database.db" diff --git a/app/src/main/java/com/pasich/encly/presentation/viewmodel/LockViewModel.kt b/app/src/main/java/com/pasich/encly/presentation/viewmodel/LockViewModel.kt index be78e3b..e5f95df 100644 --- a/app/src/main/java/com/pasich/encly/presentation/viewmodel/LockViewModel.kt +++ b/app/src/main/java/com/pasich/encly/presentation/viewmodel/LockViewModel.kt @@ -3,12 +3,14 @@ package com.pasich.encly.presentation.viewmodel import androidx.fragment.app.FragmentActivity import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.pasich.encly.core.di.ApplicationScope import com.pasich.encly.core.security.AuthStrategy import com.pasich.encly.core.security.SecurityManager import com.pasich.encly.core.security.SensitiveDataCleaner import com.pasich.encly.core.security.SessionLockManager import com.pasich.encly.core.security.VaultUnlockResult import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.NonCancellable @@ -34,6 +36,8 @@ enum class SeedUnlockResult { SUCCESS, WRONG_SEED, DB_ERROR, BACKGROUNDED } class LockViewModel @Inject constructor( private val securityManager: SecurityManager, private val sessionLockManager: SessionLockManager, + /** Where the rest of a wipe-PIN erase runs once the vault is open; outlives this screen. */ + @param:ApplicationScope private val appScope: CoroutineScope, ) : ViewModel() { private val _busy = MutableStateFlow(false) @@ -77,6 +81,8 @@ class LockViewModel @Inject constructor( VaultUnlockResult.DB_ERROR -> PinUnlockResult.DB_ERROR } } + // The rest of a wipe-PIN erase (if that was one) runs while the vault is revealed. + if (result == PinUnlockResult.SUCCESS) appScope.launch { securityManager.completePendingWipe() } publish(result == PinUnlockResult.SUCCESS, result, PinUnlockResult.BACKGROUNDED) } } diff --git a/app/src/test/java/com/pasich/encly/core/security/WipePinTest.kt b/app/src/test/java/com/pasich/encly/core/security/WipePinTest.kt index cae1199..f151f81 100644 --- a/app/src/test/java/com/pasich/encly/core/security/WipePinTest.kt +++ b/app/src/test/java/com/pasich/encly/core/security/WipePinTest.kt @@ -19,12 +19,17 @@ import org.junit.Before import org.junit.Test import org.mockito.ArgumentMatchers.anyBoolean import org.mockito.ArgumentMatchers.eq +import org.mockito.Mockito.doReturn import org.mockito.Mockito.inOrder import org.mockito.Mockito.mock import org.mockito.Mockito.never +import org.mockito.Mockito.times import org.mockito.Mockito.verify import org.mockito.Mockito.`when` import java.io.File +import java.io.IOException +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit /** * The wipe PIN (issue #52): a second PIN that, typed on the lock screen, erases the vault and @@ -317,6 +322,37 @@ class WipePinTest { assertArrayEquals(dek, (auth.unlockWithPin(pin("246810")) as PinUnlock.Success).dek) } + @Test + fun anEraseThatFailsLeavesNoSecondKeyAndCanBeTriedAgain() { + assertTrue(auth.configurePin(pin(PIN), dek)) + assertEquals(WipePinChange.SET, auth.configureWipePin(pin(WIPE_PIN))) + // The new key was made, but deriving the new slot's key with it fails. + factor.failingSlots = setOf(PinKeySlot.B) + + assertEquals(PinUnlock.Failed, auth.unlockWithPin(pin(WIPE_PIN))) + + assertFalse(factor.hasKey(PinKeySlot.B)) + assertNull(auth.pendingWipe()) + factor.failingSlots = emptySet() + assertTrue(auth.unlockWithPin(pin(WIPE_PIN)) is PinUnlock.Erased) + } + + @Test + fun anEraseWhoseStoreWriteFailsLeavesNoSecondKey() { + assertTrue(auth.configurePin(pin(PIN), dek)) + assertEquals(WipePinChange.SET, auth.configureWipePin(pin(WIPE_PIN))) + // The attempt is counted (one write), then phase 2's write fails. + var writes = 0 + store.beforeRename = { if (++writes == 2) throw IOException("disk full") } + + assertEquals(PinUnlock.Failed, auth.unlockWithPin(pin(WIPE_PIN))) + + store.beforeRename = null + assertFalse(factor.hasKey(PinKeySlot.B)) + assertNull(auth.pendingWipe()) + assertArrayEquals(dek, (auth.unlockWithPin(pin(PIN)) as PinUnlock.Success).dek) + } + @Test fun removingTheWipePinAlsoClearsTheNotice() { assertTrue(auth.configurePin(pin(PIN), dek)) @@ -368,7 +404,7 @@ class WipePinTest { val restarted = vault.restart() assertEquals(InitialStatus.AUTH, restarted.resolveInitialStatus()) - verify(vault.database).wipe() + verify(vault.database).wipeIfClosed() verify(vault.biometric).disable() assertFalse(vault.prefs.contains(BackupManager.LAST_EXPORT_KEY)) assertFalse(vault.factor.hasKey(PinKeySlot.A)) @@ -379,6 +415,8 @@ class WipePinTest { assertEquals(VaultUnlockResult.SUCCESS, restarted.unlockWithPin(pin(WIPE_PIN))) verify(vault.database).unlockDatabase(anyByteArray(), eq(true)) + assertEquals(WipeStage.CLEANUP, vault.auth.pendingWipe()) + restarted.completePendingWipe() assertNull(vault.auth.pendingWipe()) } @@ -392,6 +430,7 @@ class WipePinTest { assertEquals(InitialStatus.AUTH, vault.restart().resolveInitialStatus()) verify(vault.database, never()).wipe() + verify(vault.database, never()).wipeIfClosed() verify(vault.biometric).disable() assertNull(vault.auth.pendingWipe()) } @@ -408,10 +447,14 @@ class WipePinTest { val order = inOrder(vault.database) order.verify(vault.database).wipe() order.verify(vault.database).unlockDatabase(anyByteArray(), eq(true)) + assertNotEquals(epoch, vault.security.eraseEpoch()) + // The rest waits until the vault is shown, so the wipe PIN does not wait for it. + assertEquals(WipeStage.CLEANUP, vault.auth.pendingWipe()) + assertTrue(vault.factor.hasKey(PinKeySlot.A)) + vault.security.completePendingWipe() assertNull(vault.auth.pendingWipe()) assertFalse(vault.factor.hasKey(PinKeySlot.A)) assertFalse(vault.prefs.contains(BackupManager.LAST_EXPORT_KEY)) - assertNotEquals(epoch, vault.security.eraseEpoch()) // Onboarding stays committed: a restart asks for the PIN of the empty vault. `when`(vault.database.hasEncryptedDatabase()).thenReturn(true) assertEquals(InitialStatus.AUTH, vault.restart().resolveInitialStatus()) @@ -444,7 +487,8 @@ class WipePinTest { keys += (it.arguments[0] as ByteArray).copyOf() to (it.arguments[1] as Boolean) true } - val security = SecurityManager(vault.prefs, store, SeedPhraseManager(store), database, auth, vault.biometric) + val security = + SecurityManager(vault.prefs, store, SeedPhraseManager(store), database, auth, vault.biometric) assertEquals(InitialStatus.AUTH, security.resolveInitialStatus()) assertEquals(VaultUnlockResult.INVALID_CREDENTIAL, security.unlockWithPin(pin(PIN))) @@ -459,6 +503,7 @@ class WipePinTest { fun aNormalUnlockIsNotAnErasedVaultSession() { val vault = Vault() `when`(vault.database.unlockDatabase(anyByteArray(), anyBoolean())).thenReturn(true) + val epoch = vault.security.eraseEpoch() assertEquals(VaultUnlockResult.SUCCESS, vault.security.unlockWithPin(pin(PIN))) @@ -480,9 +525,106 @@ class WipePinTest { assertEquals(VaultUnlockResult.SUCCESS, vault.security.unlockWithPin(pin(WIPE_PIN))) assertNotEquals(epoch, vault.security.eraseEpoch()) + assertEquals(WipeStage.CLEANUP, vault.auth.pendingWipe()) + } + + @Test + fun anErasedDatabaseThatCannotBeRecordedIsClosedAndCreatedAgainByTheNextUnlock() { + val vault = Vault() + // The erase itself is written; recording the new database is not. + `when`(vault.database.unlockDatabase(anyByteArray(), anyBoolean())).thenAnswer { + vault.store.beforeRename = { throw IOException("disk full") } + true + } + + assertEquals(VaultUnlockResult.DB_ERROR, vault.security.unlockWithPin(pin(WIPE_PIN))) + + verify(vault.database).reset() + assertEquals(WipeStage.DATABASE, vault.auth.pendingWipe()) + assertNull(vault.security.copyBackupRootKey()) + vault.store.beforeRename = null + doReturn(true).`when`(vault.database).unlockDatabase(anyByteArray(), anyBoolean()) + + assertEquals(VaultUnlockResult.SUCCESS, vault.security.unlockWithPin(pin(WIPE_PIN))) + verify(vault.database, times(2)).unlockDatabase(anyByteArray(), eq(true)) + assertEquals(WipeStage.CLEANUP, vault.auth.pendingWipe()) + } + + @Test + fun theStartupFinishCannotDeleteTheDatabaseAnUnlockIsCreating() { + // A recreated activity resolves the startup state again while the wipe PIN's unlock is + // creating the empty database on another thread. + val vault = Vault() + val creating = CountDownLatch(1) + val release = CountDownLatch(1) + `when`(vault.database.unlockDatabase(anyByteArray(), anyBoolean())).thenAnswer { + creating.countDown() + release.await(5, TimeUnit.SECONDS) + true + } + `when`(vault.database.hasEncryptedDatabase()).thenReturn(true) + var unlocked: VaultUnlockResult? = null + val unlock = thread { unlocked = vault.security.unlockWithPin(pin(WIPE_PIN)) } + assertTrue(creating.await(5, TimeUnit.SECONDS)) + + val startup = thread { vault.security.completePendingWipe() } + startup.join(STARTUP_WAIT_MS) + assertTrue("the finish waits for the unlock", startup.isAlive) + release.countDown() + unlock.join() + startup.join() + assertEquals(VaultUnlockResult.SUCCESS, unlocked) + + // Only the unlock's own delete of the old database, before creating the new one. + verify(vault.database).wipe() + verify(vault.database, never()).wipeIfClosed() + // The finish ran after the database was recorded: the cleanup, not the database step. assertNull(vault.auth.pendingWipe()) } + @Test + fun theStartupFinishRunsOncePerProcess() { + val vault = Vault() + assertTrue(vault.auth.unlockWithPin(pin(WIPE_PIN)) is PinUnlock.Erased) + val restarted = vault.restart() + assertEquals(InitialStatus.AUTH, restarted.resolveInitialStatus()) + verify(vault.database).wipeIfClosed() + + // The activity is recreated (a rotation): the same process resolves the state again. + assertEquals(InitialStatus.AUTH, restarted.resolveInitialStatus()) + + verify(vault.database).wipeIfClosed() + } + + @Test + fun aKeyLeftByAnEraseThatNeverCommittedIsDeletedAtTheNextStart() { + val vault = Vault() + // Phase 1 made key B, then the process died before phase 2 recorded it. + vault.factor.reset(PinKeySlot.B) + `when`(vault.database.hasEncryptedDatabase()).thenReturn(true) + + assertEquals(InitialStatus.AUTH, vault.restart().resolveInitialStatus()) + + assertFalse(vault.factor.hasKey(PinKeySlot.B)) + assertTrue(vault.factor.hasKey(PinKeySlot.A)) + `when`(vault.database.unlockDatabase(anyByteArray(), anyBoolean())).thenReturn(true) + assertEquals(VaultUnlockResult.SUCCESS, vault.security.unlockWithPin(pin(PIN))) + } + + @Test + fun theStartupKeepsTheKeyOfAnEraseThatIsPending() { + val vault = Vault() + assertTrue(vault.auth.unlockWithPin(pin(WIPE_PIN)) is PinUnlock.Erased) + assertTrue(vault.auth.markWipeDatabaseCreated()) + `when`(vault.database.hasEncryptedDatabase()).thenReturn(true) + + assertEquals(InitialStatus.AUTH, vault.restart().resolveInitialStatus()) + + // B is the active key now; the retired one, A, went with the cleanup. + assertTrue(vault.factor.hasKey(PinKeySlot.B)) + assertFalse(vault.factor.hasKey(PinKeySlot.A)) + } + @Test fun startupAddsTheDecoyToAnExistingVault() { val vault = Vault(withWipePin = false) @@ -540,6 +682,8 @@ class WipePinTest { fun restart() = SecurityManager(prefs, store, seed, database, auth, biometric) } + private fun thread(block: () -> Unit): Thread = Thread(block).apply { start() } + private fun configured(withWipePin: Boolean): AuthenticationManager { val manager = AuthenticationManager(tempVaultStore(), FakePinFactor(), FakeLockoutClock()) assertTrue(manager.configurePin(pin(PIN), dek)) @@ -567,6 +711,7 @@ class WipePinTest { const val SALT_SIZE = 16 const val NANOS_PER_MILLI = 1_000_000L const val TIMING_SLACK_MS = 250L + const val STARTUP_WAIT_MS = 300L const val PIN_SLOT = "pin.slot" const val WIPE_SLOT = "pin.wipe.slot" const val KEY_SLOT = "pin.key_slot" diff --git a/app/src/test/java/com/pasich/encly/presentation/viewmodel/LockViewModelTest.kt b/app/src/test/java/com/pasich/encly/presentation/viewmodel/LockViewModelTest.kt index e58273b..42c017d 100644 --- a/app/src/test/java/com/pasich/encly/presentation/viewmodel/LockViewModelTest.kt +++ b/app/src/test/java/com/pasich/encly/presentation/viewmodel/LockViewModelTest.kt @@ -17,6 +17,7 @@ import com.pasich.encly.testutil.anyCallback import com.pasich.encly.testutil.anyCharArray import com.pasich.encly.testutil.eqValue import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.UnconfinedTestDispatcher @@ -57,7 +58,7 @@ class LockViewModelTest { security = mock(SecurityManager::class.java) sessionLock = SessionLockManager(security) sessionLock.onStart(mock(LifecycleOwner::class.java)) - viewModel = LockViewModel(security, sessionLock) + viewModel = LockViewModel(security, sessionLock, CoroutineScope(Dispatchers.Unconfined)) } @After @@ -74,6 +75,18 @@ class LockViewModelTest { assertFalse(sessionLock.locked.value) } + @Test + fun theRestOfAWipePinEraseRunsAfterThePinUnlockedAndOnlyThen() = runTest { + `when`(security.unlockWithPin(PIN.toCharArray())).thenReturn(VaultUnlockResult.INVALID_CREDENTIAL) + pin() + verify(security, never()).completePendingWipe() + + `when`(security.unlockWithPin(PIN.toCharArray())).thenReturn(VaultUnlockResult.SUCCESS) + assertEquals(PinUnlockResult.SUCCESS, pin()) + + verify(security).completePendingWipe() + } + @Test fun aNoteIsReopenedOnlyInTheVaultItWasRememberedIn() { `when`(security.eraseEpoch()).thenReturn(7L) diff --git a/app/src/test/java/com/pasich/encly/testutil/SecurityFakes.kt b/app/src/test/java/com/pasich/encly/testutil/SecurityFakes.kt index 4769d26..a8b23f0 100644 --- a/app/src/test/java/com/pasich/encly/testutil/SecurityFakes.kt +++ b/app/src/test/java/com/pasich/encly/testutil/SecurityFakes.kt @@ -14,15 +14,18 @@ import javax.crypto.spec.SecretKeySpec /** * A [PinHardwareFactor] with software HMAC keys standing in for the Keystore ones, one per * [PinKeySlot]. [lost] and [failing] simulate an invalidated key and a transient Keystore error - * (for every slot); [failingReset] a key that cannot be generated. [delete] removes a key the - * way the system can, so [ensureKey] then reports a new one. [calls] counts MACs, i.e. PIN - * guesses that reached the "hardware". + * (for every slot), [failingSlots] one for some slots only; [failingReset] a key that cannot be + * generated. [delete] removes a key the way the system can, so [ensureKey] then reports a new + * one. [calls] counts MACs, i.e. PIN guesses that reached the "hardware". */ internal class FakePinFactor : PinHardwareFactor { private val keys = mutableMapOf() var lost = false var failing = false var failingReset = false + + /** Slots whose MAC fails like a transient Keystore error, while the others still work. */ + var failingSlots: Set = emptySet() var calls = 0 private set var resets = 0 @@ -45,7 +48,7 @@ internal class FakePinFactor : PinHardwareFactor { } override fun mac(slot: PinKeySlot, data: ByteArray): ByteArray { - if (failing) throw PinFactorException(lost = false) + if (failing || slot in failingSlots) throw PinFactorException(lost = false) val current = keys[slot] if (lost || current == null) throw PinFactorException(lost = true) calls++ diff --git a/app/src/test/java/com/pasich/encly/ui/screens/TestApp.kt b/app/src/test/java/com/pasich/encly/ui/screens/TestApp.kt index a90e3b7..b3b5975 100644 --- a/app/src/test/java/com/pasich/encly/ui/screens/TestApp.kt +++ b/app/src/test/java/com/pasich/encly/ui/screens/TestApp.kt @@ -165,7 +165,7 @@ internal class TestApp(context: Context) { }, ) - fun lock() = LockViewModel(security, sessionLock) + fun lock() = LockViewModel(security, sessionLock, appScope) fun securitySettings() = SecuritySettingsViewModel( security, From 216446d8ed2ef446d79a7442cc4c646f48a5d88d Mon Sep 17 00:00:00 2001 From: pasichdev Date: Mon, 5 Oct 2026 12:47:54 +0300 Subject: [PATCH 12/19] fix(tasks): keep the sub-task segment bar within its width The segment bar gives each sub-task a segment of at least 1 dp, 1 dp apart, and did not clip. With more than about 80 sub-tasks the segments no longer fit in the bar's 160 dp and were drawn past it. When they do not fit, the bar now draws as many segments as fit, done ones first, in the same proportion of done to open (rounded, with at least one of each kind there is), and clips to its bounds. A unit test checks the layout math. Refs: #55 --- .../encly/presentation/designsystem/Tasks.kt | 31 ++++++++++-- .../designsystem/SegmentBarTest.kt | 48 +++++++++++++++++++ 2 files changed, 74 insertions(+), 5 deletions(-) create mode 100644 app/src/test/java/com/pasich/encly/presentation/designsystem/SegmentBarTest.kt diff --git a/app/src/main/java/com/pasich/encly/presentation/designsystem/Tasks.kt b/app/src/main/java/com/pasich/encly/presentation/designsystem/Tasks.kt index 2e32772..43b4956 100644 --- a/app/src/main/java/com/pasich/encly/presentation/designsystem/Tasks.kt +++ b/app/src/main/java/com/pasich/encly/presentation/designsystem/Tasks.kt @@ -30,6 +30,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.draw.rotate import androidx.compose.ui.geometry.CornerRadius @@ -291,8 +292,9 @@ fun EnclyRowIconButton( * Sub-task progress on a task row: one rounded segment per sub-task, 3 dp apart, at most 160 dp * wide. A done one is `primary` and 4 dp tall, an open one `outlineVariant` and 2 dp: the height * tells them apart where the palette's primary is low in chroma. Past a dozen segments the gaps - * narrow so they still fit. Starts at the start edge (mirrored in RTL). Decorative: the row - * announces the count. + * narrow so they still fit; with more sub-tasks than 1 dp segments 1 dp apart can show, fewer + * segments stand for them (see [segmentsToDraw]). Starts at the start edge (mirrored in RTL). + * Decorative: the row announces the count. */ @Composable fun EnclySegmentBar(done: List, modifier: Modifier = Modifier) { @@ -302,14 +304,16 @@ fun EnclySegmentBar(done: List, modifier: Modifier = Modifier) { modifier = modifier .widthIn(max = SEGMENT_BAR_MAX_WIDTH) .fillMaxWidth() - .height(SEGMENT_DONE_HEIGHT), + .height(SEGMENT_DONE_HEIGHT) + .clipToBounds(), ) { if (done.isEmpty()) return@Canvas - val count = done.size + val segments = segmentsToDraw(done, size.width, SEGMENT_MIN_GAP.toPx(), SEGMENT_MIN_GAP.toPx()) + val count = segments.size val gap = (SEGMENT_GAP * minOf(1f, SEGMENT_FULL_GAP_COUNT.toFloat() / count)).toPx() .coerceAtLeast(SEGMENT_MIN_GAP.toPx()) val width = ((size.width - gap * (count - 1)) / count).coerceAtLeast(SEGMENT_MIN_GAP.toPx()) - done.forEachIndexed { index, isDone -> + segments.forEachIndexed { index, isDone -> val height = (if (isDone) SEGMENT_DONE_HEIGHT else SEGMENT_OPEN_HEIGHT).toPx() val start = index * (width + gap) val left = if (rtl) size.width - start - width else start @@ -357,3 +361,20 @@ fun EnclyProgressTrack(progress: Float, modifier: Modifier = Modifier) { ) } } + +/** + * The segments [EnclySegmentBar] draws for [done] in [widthPx], each at least [minSegmentPx] + * wide and [minGapPx] apart: [done] itself when they all fit, otherwise as many as fit, done + * ones first, in the same proportion of done to open (rounded), with at least one of each kind + * there is, so a nearly finished or barely started list never looks complete or untouched. + */ +internal fun segmentsToDraw(done: List, widthPx: Float, minSegmentPx: Float, minGapPx: Float): List { + val count = done.size + val fit = ((widthPx + minGapPx) / (minSegmentPx + minGapPx)).toInt().coerceAtLeast(1) + if (count <= fit) return done + val doneCount = done.count { it } + var drawnDone = ((doneCount.toLong() * fit + count / 2) / count).toInt() + if (doneCount > 0) drawnDone = drawnDone.coerceAtLeast(1) + if (doneCount < count) drawnDone = drawnDone.coerceAtMost(fit - 1) + return List(fit) { it < drawnDone } +} diff --git a/app/src/test/java/com/pasich/encly/presentation/designsystem/SegmentBarTest.kt b/app/src/test/java/com/pasich/encly/presentation/designsystem/SegmentBarTest.kt new file mode 100644 index 0000000..324d24e --- /dev/null +++ b/app/src/test/java/com/pasich/encly/presentation/designsystem/SegmentBarTest.kt @@ -0,0 +1,48 @@ +package com.pasich.encly.presentation.designsystem + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test + +/** The segment bar's layout: never wider than its 160 dp, and honest about done and open. */ +class SegmentBarTest { + // 160 dp at 1x: 1 px segments 1 px apart, so 80 fit. + private val width = 160f + private val min = 1f + + @Test + fun segmentsThatFitAreDrawnOneBySubtaskAsTheyAre() { + val done = listOf(true, false, true, false) + + assertSame(done, segmentsToDraw(done, width, min, min)) + } + + @Test + fun moreSubtasksThanFitAreDrawnAsFewerSegmentsWithinTheWidth() { + val done = List(200) { it % 4 == 0 } + + val drawn = segmentsToDraw(done, width, min, min) + + assertEquals(80, drawn.size) + // 80 segments and 79 gaps of at least 1 px each stay inside 160 px. + assertTrue(drawn.size * min + (drawn.size - 1) * min <= width) + // A quarter done: 20 of 80, done ones first. + assertEquals(List(80) { it < 20 }, drawn) + } + + @Test + fun oneDoneOrOneOpenAmongManyStillShows() { + val oneDone = List(500) { it == 7 } + val oneOpen = List(500) { it != 7 } + + assertEquals(1, segmentsToDraw(oneDone, width, min, min).count { it }) + assertEquals(1, segmentsToDraw(oneOpen, width, min, min).count { !it }) + } + + @Test + fun allDoneOrNoneDoneStaysSo() { + assertTrue(segmentsToDraw(List(300) { true }, width, min, min).all { it }) + assertTrue(segmentsToDraw(List(300) { false }, width, min, min).none { it }) + } +} From 212b7db4c4e3d2caf04d84eb2b755fbda71dd586 Mon Sep 17 00:00:00 2001 From: pasichdev Date: Mon, 5 Oct 2026 12:56:45 +0300 Subject: [PATCH 13/19] fix(tasks): keep sub-task edits intact across rotation, lock waits and reorders Review findings on the sub-tasks of the Tasks list and the task sheet: - Rotating with the sheet open saved its checklist in the background, but the recreated sheet started from the checklist loaded when it opened, and Save then wrote that back: new rows were deleted, deleted ones came back, renames and ticks were reverted. The sheet's checklist now lives in TasksViewModel, and a background save gives the rows it stored their ids and uids (and publishes the stored checklist), so the next save updates them. - Rotating while typing a new sub-task in the list's inline field saved the half-typed title and cleared the field, through the pause flush and the field's focus loss. Both now skip a configuration change; the field lives in the ViewModel and comes back with its text. Backgrounding still saves it on pause, well before the re-lock. - Snackbar events were emitted while the sub-task lock was held, so a backlog of snackbars held every later sub-task write, the sheet's load and the background save. They are now sent after the lock is released, none dropped. - A double tap on the last open checkbox offered "Complete task" twice: the state before the tick was derived from the state after it. It is now read before the write, and a tick already stored is skipped. - Move up/Move down sat on a row TalkBack never focuses. They are now on the sub-task's title in the tree and on the drag handle in the sheet. - A reorder passed positions, which point at another row when a delete is in flight. It now passes the moved row's id and the id of the row whose place it takes, and the tree shows the stored order again when the move is not stored. - Completing or reopening a task passes through a list update where it is in neither list, which folded its open tree. Open trees are now dropped only for tasks deleted here, and once against the first list for ids restored from the saved state. Each has a test: ViewModel tests for the sheet (new and edited task), the snackbar backlog, the double tap, the reorders and the completed tree, a Robolectric recreation test for the inline field, and Compose tests for the TalkBack actions. Refs: #55 --- .../components/tasks/SubtaskTree.kt | 93 ++++---- .../designsystem/SubtaskTreeRows.kt | 16 +- .../dialogs/tasks/AddTaskDialog.kt | 5 +- .../dialogs/tasks/SubtaskEditor.kt | 63 ++++-- .../encly/presentation/screen/TasksScreen.kt | 16 +- .../presentation/viewmodel/SubtaskDrafts.kt | 24 ++ .../presentation/viewmodel/TasksViewModel.kt | 208 ++++++++++++++---- .../TasksViewModelInlineSubtasksTest.kt | 71 +++++- .../viewmodel/TasksViewModelSubtasksTest.kt | 116 ++++++++++ .../java/com/pasich/encly/ui/screens/Fakes.kt | 8 +- .../ui/screens/TasksScreenRecreationTest.kt | 114 ++++++++++ .../encly/ui/screens/TasksScreenTest.kt | 41 ++++ 12 files changed, 654 insertions(+), 121 deletions(-) create mode 100644 app/src/test/java/com/pasich/encly/ui/screens/TasksScreenRecreationTest.kt diff --git a/app/src/main/java/com/pasich/encly/presentation/components/tasks/SubtaskTree.kt b/app/src/main/java/com/pasich/encly/presentation/components/tasks/SubtaskTree.kt index b98aef4..41160cf 100644 --- a/app/src/main/java/com/pasich/encly/presentation/components/tasks/SubtaskTree.kt +++ b/app/src/main/java/com/pasich/encly/presentation/components/tasks/SubtaskTree.kt @@ -2,6 +2,7 @@ package com.pasich.encly.presentation.components.tasks import android.provider.Settings import androidx.activity.compose.BackHandler +import androidx.activity.compose.LocalActivity import androidx.compose.animation.core.updateTransition import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.layout.Column @@ -14,6 +15,7 @@ import androidx.compose.foundation.relocation.bringIntoViewRequester import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState import androidx.compose.runtime.getValue import androidx.compose.runtime.key import androidx.compose.runtime.mutableLongStateOf @@ -32,7 +34,6 @@ import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.CustomAccessibilityAction import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.customActions import androidx.compose.ui.semantics.semantics import com.pasich.encly.R import com.pasich.encly.data.model.Subtask @@ -69,8 +70,11 @@ class SubtaskTreeActions( val onToggle: (Subtask, Boolean) -> Unit, val onStartAdding: (taskId: Long) -> Unit, val onStartRenaming: (Subtask) -> Unit, - /** Drag or "Move up/down" within the task's tree. */ - val onMove: (taskId: Long, from: Int, to: Int) -> Unit, + /** + * Drag or "Move up/down" within the task's tree: the sub-task [movedId] takes the place of + * [targetId]. The tree calls [onFailed] back when the new order was not stored. + */ + val onMove: (taskId: Long, movedId: Long, targetId: Long, onFailed: () -> Unit) -> Unit, /** The chevron: opens the whole tree, or folds it back to the next step. */ val onToggleTree: (taskId: Long) -> Unit, val inline: InlineSubtaskActions, @@ -158,33 +162,35 @@ internal fun SubtaskTree( enabled: Boolean = true, ) { val haptics = LocalHapticFeedback.current - // Shows a dropped row in its new place until the stored order comes back. - var rows by remember(subtasks) { mutableStateOf(subtasks) } + // Shows a dropped row in its new place until the stored order comes back (see moveRow). + val rows = remember(subtasks) { mutableStateOf(subtasks) } + val currentSubtasks by rememberUpdatedState(subtasks) val collapse = stringResource(R.string.subtasks_hide) val chevron: @Composable () -> Unit = { EnclyExpandButton(expanded = true, contentDescription = collapse, onClick = { actions.onToggleTree(taskId) }) } val move = { from: Int, to: Int -> - rows = rows.toMutableList().apply { add(to, removeAt(from)) } - actions.onMove(taskId, from, to) + rows.moveRow(from, to) { moved, target -> + actions.onMove(taskId, moved, target) { rows.value = currentSubtasks } + } } Column(modifier = modifier) { ReorderableColumn( - list = rows, + list = rows.value, onSettle = move, onMove = { haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove) }, ) { index, subtask, dragging -> key(subtask.id) { val renaming = edit != null && edit.target == InlineSubtaskTarget.Rename(taskId, subtask.id) - val reorder = Modifier - .longPressDraggableHandle( - enabled = enabled && !renaming, - onDragStarted = { haptics.performHapticFeedback(HapticFeedbackType.LongPress) }, - ) - .moveActions( - moveUp = { move(index, index - 1) }.takeIf { index > 0 }, - moveDown = { move(index, index + 1) }.takeIf { index < rows.lastIndex }, - ) + val reorder = Modifier.longPressDraggableHandle( + enabled = enabled && !renaming, + onDragStarted = { haptics.performHapticFeedback(HapticFeedbackType.LongPress) }, + ) + // Given to the title: the node TalkBack focuses (a plain row is not one). + val moves = moveActions( + moveUp = { move(index, index - 1) }.takeIf { index > 0 }, + moveDown = { move(index, index + 1) }.takeIf { index < rows.value.lastIndex }, + ) if (renaming) { RenameField(subtask, edit, actions, enabled) } else { @@ -199,6 +205,7 @@ internal fun SubtaskTree( enabled = enabled, onClick = { actions.onStartRenaming(subtask) }, onClickLabel = stringResource(R.string.edit), + customActions = moves, dragging = dragging, trailing = chevron.takeIf { index == 0 }, ) @@ -211,34 +218,41 @@ internal fun SubtaskTree( actions, enabled, first = subtasks.isEmpty(), - trailing = chevron.takeIf { - subtasks.isEmpty() - }, + trailing = chevron.takeIf { subtasks.isEmpty() }, ) } } +/** + * Moves the row at [from] to [to] at once and asks [store] to store that by ids (the moved row + * and the one whose place it takes); the caller puts the stored order back if that fails. + */ +private fun MutableState>.moveRow(from: Int, to: Int, store: (movedId: Long, targetId: Long) -> Unit) { + val current = value + if (from !in current.indices || to !in current.indices || from == to) return + value = current.toMutableList().apply { add(to, removeAt(from)) } + store(current[from].id, current[to].id) +} + /** "Move up" and "Move down" for TalkBack, where the row can move that way. */ @Composable -private fun Modifier.moveActions(moveUp: (() -> Unit)?, moveDown: (() -> Unit)?): Modifier { +private fun moveActions(moveUp: (() -> Unit)?, moveDown: (() -> Unit)?): List { val up = stringResource(R.string.tag_move_up) val down = stringResource(R.string.tag_move_down) - return semantics { - customActions = listOfNotNull( - moveUp?.let { action -> - CustomAccessibilityAction(up) { - action() - true - } - }, - moveDown?.let { action -> - CustomAccessibilityAction(down) { - action() - true - } - }, - ) - } + return listOfNotNull( + moveUp?.let { action -> + CustomAccessibilityAction(up) { + action() + true + } + }, + moveDown?.let { action -> + CustomAccessibilityAction(down) { + action() + true + } + }, + ) } /** The add leaf (└ + Sub-task, or + First step), or the inline field it turned into. */ @@ -318,6 +332,9 @@ private fun InlineSubtaskField( var focused by remember { mutableStateOf(false) } val currentActions by rememberUpdatedState(actions) val close = { currentActions.onClose(target) } + // A rotation disposes the field, which takes its focus away: that is not the user leaving it + // (the field lives in the ViewModel and comes back open, with its text). + val activity = LocalActivity.current LaunchedEffect(Unit) { focusRequester.requestFocus() } BackHandler(onBack = close) @@ -342,7 +359,7 @@ private fun InlineSubtaskField( focused = true } else if (focused) { focused = false - close() + if (activity?.isChangingConfigurations != true) close() } }, done = done, diff --git a/app/src/main/java/com/pasich/encly/presentation/designsystem/SubtaskTreeRows.kt b/app/src/main/java/com/pasich/encly/presentation/designsystem/SubtaskTreeRows.kt index d4c5829..95a1eb2 100644 --- a/app/src/main/java/com/pasich/encly/presentation/designsystem/SubtaskTreeRows.kt +++ b/app/src/main/java/com/pasich/encly/presentation/designsystem/SubtaskTreeRows.kt @@ -37,8 +37,10 @@ import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.semantics.CustomAccessibilityAction import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.customActions import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType @@ -107,8 +109,9 @@ fun EnclyStepTransition( * A sub-task in its task's tree: a [CheckboxSize.SMALL] checkbox and a bodyMedium title, struck * through and muted when done, and an optional [trailing] action. [dimmed] mutes the title of a * done task's sub-task. The checkbox announces [checkboxDescription] (the title by default); - * [onClick] (the title) is announced with [onClickLabel]. While [dragging] the row is lifted on - * `surfaceContainerHigh` and leaves its connector behind. + * [onClick] (the title) is announced with [onClickLabel], and the title carries + * [customActions] (such as Move up/down), where TalkBack offers them. While [dragging] the row + * is lifted on `surfaceContainerHigh` and leaves its connector behind. */ @Suppress("LongParameterList") // A row's content, state and callbacks, like EnclyTaskRow. @Composable @@ -123,6 +126,7 @@ fun EnclySubtaskRow( onClick: (() -> Unit)? = null, onClickLabel: String? = null, checkboxDescription: String = title, + customActions: List = emptyList(), dragging: Boolean = false, trailing: (@Composable () -> Unit)? = null, ) { @@ -154,6 +158,14 @@ fun EnclySubtaskRow( Modifier }, ) + .then( + if (customActions.isNotEmpty()) { + // Same node as the clickable title above: one semantics node per layout node. + Modifier.semantics { this.customActions = customActions } + } else { + Modifier + }, + ) .padding(start = spacing.xxs, end = spacing.xs), ) { Text( diff --git a/app/src/main/java/com/pasich/encly/presentation/dialogs/tasks/AddTaskDialog.kt b/app/src/main/java/com/pasich/encly/presentation/dialogs/tasks/AddTaskDialog.kt index facc04d..d244f6e 100644 --- a/app/src/main/java/com/pasich/encly/presentation/dialogs/tasks/AddTaskDialog.kt +++ b/app/src/main/java/com/pasich/encly/presentation/dialogs/tasks/AddTaskDialog.kt @@ -93,11 +93,14 @@ fun AddTaskDialog( onBackgroundSave: (TaskDraft) -> Unit = {}, onDeleteTask: ((Task) -> Unit)? = null, editSubtasks: List? = emptyList(), + subtasks: SubtaskListState? = null, ) { var title by remember { mutableStateOf(editTask?.title.orEmpty()) } var description by remember { mutableStateOf(editTask?.description.orEmpty()) } var selectedPriority by remember { mutableIntStateOf(editTask?.priority ?: 0) } - val checklist = rememberSubtaskListState(editSubtasks) + // The caller's checklist (TasksViewModel's, which outlives a rotation), or one of its own. + val ownChecklist = rememberSubtaskListState(editSubtasks) + val checklist = subtasks ?: ownChecklist val titleFocusRequester = remember { FocusRequester() } val state = TaskEditorState( diff --git a/app/src/main/java/com/pasich/encly/presentation/dialogs/tasks/SubtaskEditor.kt b/app/src/main/java/com/pasich/encly/presentation/dialogs/tasks/SubtaskEditor.kt index f2f8e44..a0dff6d 100644 --- a/app/src/main/java/com/pasich/encly/presentation/dialogs/tasks/SubtaskEditor.kt +++ b/app/src/main/java/com/pasich/encly/presentation/dialogs/tasks/SubtaskEditor.kt @@ -35,6 +35,7 @@ import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextDecoration import com.pasich.encly.R +import com.pasich.encly.data.model.Subtask import com.pasich.encly.presentation.designsystem.CheckboxSize import com.pasich.encly.presentation.designsystem.EnclyCheckbox import com.pasich.encly.presentation.designsystem.EnclyIcons @@ -53,7 +54,7 @@ internal const val SUBTASK_TITLE_MAX_LENGTH = 100 * loaded) and the text of the "add" field. */ @Stable -internal class SubtaskListState(initial: List?) { +class SubtaskListState(initial: List?) { var subtasks by mutableStateOf(initial) private set var newTitle by mutableStateOf("") @@ -67,6 +68,18 @@ internal class SubtaskListState(initial: List?) { if (subtasks == null && loaded != null) subtasks = loaded } + /** + * A background save stored the rows: each new row in [stored] (by its key) takes the id and + * uid it got, so the next save updates it rather than adding it again. Edits made since are + * kept. + */ + fun adoptStored(stored: Map) { + subtasks = subtasks?.map { row -> + val saved = stored[row.key] + if (saved != null && row.id == 0L) row.copy(id = saved.id, uid = saved.uid) else row + } + } + /** The checklist to save: a title still in the "add" field counts as a new sub-task. */ fun toSave(): List? = subtasks?.let { rows -> if (newTitle.isBlank()) rows else rows + SubtaskDraft(key = nextKey, title = newTitle) @@ -145,26 +158,24 @@ private fun ReorderableScope.SubtaskRow( ) { val moveUpLabel = stringResource(R.string.tag_move_up) val moveDownLabel = stringResource(R.string.tag_move_down) + // On the drag handle, a node TalkBack focuses (the row itself is not one). + val moves = listOfNotNull( + moveUp?.let { up -> + CustomAccessibilityAction(moveUpLabel) { + up() + true + } + }, + moveDown?.let { down -> + CustomAccessibilityAction(moveDownLabel) { + down() + true + } + }, + ) Row( verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - .semantics { - customActions = listOfNotNull( - moveUp?.let { up -> - CustomAccessibilityAction(moveUpLabel) { - up() - true - } - }, - moveDown?.let { down -> - CustomAccessibilityAction(moveDownLabel) { - down() - true - } - }, - ) - }, + modifier = Modifier.fillMaxWidth(), ) { EnclyCheckbox( checked = subtask.isCompleted, @@ -186,19 +197,25 @@ private fun ReorderableScope.SubtaskRow( modifier = Modifier.size(EnclyTheme.spacing.iconSmall), ) } - DragHandle(Modifier.draggableHandle()) + DragHandle(moves = moves, modifier = Modifier.draggableHandle()) } } -/** The grip a row is dragged by; [modifier] carries the reorderable drag gesture. */ +/** + * The grip a row is dragged by; [modifier] carries the reorderable drag gesture, and [moves] + * the same moves for TalkBack. + */ @Composable -private fun DragHandle(modifier: Modifier = Modifier) { +private fun DragHandle(moves: List, modifier: Modifier = Modifier) { val description = stringResource(R.string.tag_drag_handle) Box( contentAlignment = Alignment.Center, modifier = modifier .size(EnclyTheme.spacing.minTouchTarget) - .semantics { contentDescription = description }, + .semantics { + contentDescription = description + customActions = moves + }, ) { Icon( EnclyIcons.Grip, diff --git a/app/src/main/java/com/pasich/encly/presentation/screen/TasksScreen.kt b/app/src/main/java/com/pasich/encly/presentation/screen/TasksScreen.kt index 2d09039..5e1df58 100644 --- a/app/src/main/java/com/pasich/encly/presentation/screen/TasksScreen.kt +++ b/app/src/main/java/com/pasich/encly/presentation/screen/TasksScreen.kt @@ -1,6 +1,7 @@ package com.pasich.encly.presentation.screen import android.content.Context +import androidx.activity.compose.LocalActivity import androidx.annotation.StringRes import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.padding @@ -68,7 +69,7 @@ fun TasksScreen( val listState = rememberTasksListState(uiState) val list = TasksListState(uiState, viewModel.expandedTaskIds.collectAsState().value, viewModel.inlineEdit) val itemActions = remember(viewModel) { taskItemActions(viewModel) } - // Backgrounding re-locks the vault and drops this screen: save the inline field first. + // Backgrounding re-locks the vault and drops this screen: save the inline field first (see OnPause). OnPause(viewModel::flushInlineEditForBackground) FoldTreesOnBack(list, viewModel::collapseLastExpanded) @@ -110,7 +111,7 @@ fun TasksScreen( onEditTask = viewModel::editTask, onBackgroundSave = viewModel::saveDraftForBackground, onDeleteTask = viewModel::deleteTask, - editSubtasks = viewModel.editingSubtasks.collectAsState().value, + subtasks = viewModel.checklist, sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), ) } @@ -129,14 +130,19 @@ private fun TasksFab(listState: LazyListState, onClick: () -> Unit) { EnclyFab(text = stringResource(R.string.task_add), icon = EnclyIcons.Plus, expanded = expanded, onClick = onClick) } -/** Runs [action] whenever the screen's lifecycle pauses (the app leaves the foreground). */ +/** + * Runs [action] whenever the screen's lifecycle pauses because the app leaves the foreground, + * not for a configuration change (the activity is only recreated). ON_PAUSE comes well before + * the re-lock: that waits for the process to stop and then for the auto-lock delay. + */ @Composable private fun OnPause(action: () -> Unit) { val currentAction by rememberUpdatedState(action) val lifecycleOwner = LocalLifecycleOwner.current - DisposableEffect(lifecycleOwner) { + val activity = LocalActivity.current + DisposableEffect(lifecycleOwner, activity) { val observer = LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_PAUSE) currentAction() + if (event == Lifecycle.Event.ON_PAUSE && activity?.isChangingConfigurations != true) currentAction() } lifecycleOwner.lifecycle.addObserver(observer) onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } diff --git a/app/src/main/java/com/pasich/encly/presentation/viewmodel/SubtaskDrafts.kt b/app/src/main/java/com/pasich/encly/presentation/viewmodel/SubtaskDrafts.kt index 845eddf..d3c7190 100644 --- a/app/src/main/java/com/pasich/encly/presentation/viewmodel/SubtaskDrafts.kt +++ b/app/src/main/java/com/pasich/encly/presentation/viewmodel/SubtaskDrafts.kt @@ -27,6 +27,30 @@ internal object SubtaskDrafts { return drafts.toMutableList().apply { add(to, removeAt(from)) } } + /** + * [rows] with the one whose id is [movedId] moved to where the one with [targetId] is; null + * when either is not among them (deleted meanwhile). Unchanged when they are the same. + */ + fun moveById(rows: List, movedId: Long, targetId: Long): List? { + val from = rows.indexOfFirst { it.id == movedId } + val to = rows.indexOfFirst { it.id == targetId } + if (from < 0 || to < 0) return null + return if (from == to) rows else rows.toMutableList().apply { add(to, removeAt(from)) } + } + + /** + * After [saved] was stored as [stored] (a save in the order [toSubtasks] gives), the stored + * row of each saved draft, by the draft's key. A row whose title does not match (the + * checklist changed under the save) is left out. + */ + fun storedRows(saved: List, stored: List): Map { + val kept = saved.filter { it.title.isNotBlank() } + if (kept.size != stored.size) return emptyMap() + return kept.zip(stored) + .filter { (draft, row) -> draft.title.trim() == row.title } + .associate { (draft, row) -> draft.key to row } + } + /** * The rows to store for [taskId], in this order. A row left blank is dropped, like a task * without a title is never saved. diff --git a/app/src/main/java/com/pasich/encly/presentation/viewmodel/TasksViewModel.kt b/app/src/main/java/com/pasich/encly/presentation/viewmodel/TasksViewModel.kt index 4fc3210..a8742de 100644 --- a/app/src/main/java/com/pasich/encly/presentation/viewmodel/TasksViewModel.kt +++ b/app/src/main/java/com/pasich/encly/presentation/viewmodel/TasksViewModel.kt @@ -14,6 +14,7 @@ import com.pasich.encly.data.model.Task import com.pasich.encly.domain.repository.TasksRepository import com.pasich.encly.domain.usecase.task.UpdateTaskStatusUseCase import com.pasich.encly.presentation.dialogs.tasks.SUBTASK_TITLE_MAX_LENGTH +import com.pasich.encly.presentation.dialogs.tasks.SubtaskListState import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableSharedFlow @@ -124,6 +125,14 @@ class TasksViewModel @Inject constructor( private val _editingSubtasks = MutableStateFlow?>(emptyList()) val editingSubtasks: StateFlow?> = _editingSubtasks.asStateFlow() + /** + * The sheet's checklist as it is being edited. Held here rather than in the sheet, so a + * configuration change (rotation) keeps it exactly as it was, including the ids rows got + * from a background save meanwhile. + */ + var checklist: SubtaskListState by mutableStateOf(SubtaskListState(emptyList())) + private set + /** * A task whose last open sub-task was just ticked, so the screen can offer to complete it * with one tap. The task is never completed without that tap. @@ -133,7 +142,7 @@ class TasksViewModel @Inject constructor( /** * Tasks whose sub-task tree is open on the list (all closed at first). Kept in the saved - * state, so a rotation keeps them open; ids of tasks that are gone are dropped. + * state, so a rotation keeps them open; a task deleted here is dropped from it. */ private val _expandedTaskIds = MutableStateFlow(savedStateHandle.get(KEY_EXPANDED_TASKS)?.toSet().orEmpty()) @@ -167,6 +176,9 @@ class TasksViewModel @Inject constructor( private var tasksJob: Job? = null + /** Whether the open trees restored from the saved state were checked against the first list. */ + private var restoredTreesChecked = false + init { observeTasks() clearOnLock(lockEvents) { @@ -175,6 +187,7 @@ class TasksViewModel @Inject constructor( _uiState.value = TasksUiState() _editingTask.value = null _editingSubtasks.value = emptyList() + checklist = SubtaskListState(emptyList()) deletedSubtasks.clear() _showAddTaskDialog.value = false inlineEdit = null @@ -194,16 +207,25 @@ class TasksViewModel @Inject constructor( .copy(subtasks = subtasks.groupBy { it.taskId }) }.collect { newState -> _uiState.value = newState + if (!restoredTreesChecked) { + restoredTreesChecked = true + forgetTreesOtherThan(newState) + } dropGoneListState(newState) } } } - /** Forgets open trees and the inline field of tasks (or a renamed sub-task) that are gone. */ + /** + * Closes the inline field of a task (or a renamed sub-task) that is gone. Open trees are not + * pruned on every list update: the lists and counts arrive one by one, so completing or + * reopening a task passes through a state where it is in neither list. They are checked once + * against the first list (ids restored from the saved state) and dropped where a task is + * deleted ([deleteTask], [clearCompletedTasks]); an id of a task deleted elsewhere only + * matches nothing. + */ private fun dropGoneListState(state: TasksUiState) { val ids = (state.activeTasks + state.completedTasks).mapTo(HashSet()) { it.id } - val expanded = _expandedTaskIds.value - if (!ids.containsAll(expanded)) setExpanded(expanded.filterTo(LinkedHashSet()) { it in ids }) val target = inlineEdit?.target ?: return val renamedGone = target is InlineSubtaskTarget.Rename && state.subtasks[target.taskId].orEmpty().none { it.id == target.subtaskId } @@ -234,19 +256,27 @@ class TasksViewModel @Inject constructor( return true } - /** Drag or "Move up/down" in a task's open tree: stores the new order, keeping each row's identity. */ - fun moveSubtask(taskId: Long, from: Int, to: Int) { + /** + * Drag or "Move up/down" in a task's open tree: moves the sub-task [movedId] to where + * [targetId] is in the stored order, keeping each row's identity. Ids rather than positions, + * so a row deleted (or added) meanwhile cannot make another one move. [onFailed] runs when + * nothing was stored (a row is gone, or the write failed), so the tree can show the stored + * order again. + */ + fun moveSubtask(taskId: Long, movedId: Long, targetId: Long, onFailed: () -> Unit = {}) { commitInlineEdit() launchSubtaskWrite { - val rows = tasksRepository.getSubtasks(taskId).getOrNull() ?: return@launchSubtaskWrite - if (from !in rows.indices || to !in rows.indices || from == to) return@launchSubtaskWrite - val moved = rows.toMutableList().apply { add(to, removeAt(from)) } - if (tasksRepository.saveSubtasks( - taskId, - moved, - ).isFailure - ) { - _operationFailures.emit(TaskOperationFailure.UPDATE) + val rows = tasksRepository.getSubtasks(taskId).getOrNull() + val moved = rows?.let { SubtaskDrafts.moveById(it, movedId, targetId) } + when { + moved == null -> later(onFailed) + + moved == rows -> Unit + + tasksRepository.saveSubtasks(taskId, moved).isFailure -> { + failed(TaskOperationFailure.UPDATE) + later(onFailed) + } } } } @@ -308,7 +338,7 @@ class TasksViewModel @Inject constructor( /** Undo for a sub-task deleted from the list: back at its position, with its uid. */ fun restoreSubtask(subtask: Subtask) { launchSubtaskWrite { - if (tasksRepository.restoreSubtask(subtask).isFailure) _operationFailures.emit(TaskOperationFailure.UPDATE) + if (tasksRepository.restoreSubtask(subtask).isFailure) failed(TaskOperationFailure.UPDATE) } } @@ -339,7 +369,7 @@ class TasksViewModel @Inject constructor( val title = edit.text.trim() val original = edit.original launchSubtaskWrite { - val failed = when { + val writeFailed = when { original == null -> title.isNotEmpty() && tasksRepository.addSubtask(edit.target.taskId, title).isFailure @@ -349,7 +379,7 @@ class TasksViewModel @Inject constructor( else -> false } - if (failed) _operationFailures.emit(TaskOperationFailure.UPDATE) + if (writeFailed) failed(TaskOperationFailure.UPDATE) } } @@ -358,26 +388,47 @@ class TasksViewModel @Inject constructor( * sub-task of an open task leaves only done ones, so it offers to complete the task too, * like ticking it would. */ - private suspend fun deleteSubtaskWithUndo(subtask: Subtask): Boolean { + private suspend fun PendingEvents.deleteSubtaskWithUndo(subtask: Subtask): Boolean { val deleted = tasksRepository.deleteSubtask(subtask.id).isSuccess if (deleted) { - _subtaskDeletions.emit(subtask) + events += PendingEvent.Deleted(subtask) offerCompletionAfterDelete(subtask) } return deleted } - private suspend fun offerCompletionAfterDelete(deleted: Subtask) { + private suspend fun PendingEvents.offerCompletionAfterDelete(deleted: Subtask) { val after = tasksRepository.getSubtasks(deleted.taskId).getOrNull() ?: return - val state = uiState.value - val task = (state.activeTasks + state.completedTasks).find { it.id == deleted.taskId } + val task = findTask(deleted.taskId) if (task != null && SubtaskDrafts.offersCompletion(task.isCompleted, after + deleted, after)) { - _completionOffers.emit(task.id) + events += PendingEvent.Offer(task.id) + } + } + + private fun findTask(taskId: Long): Task? = uiState.value.let { state -> + (state.activeTasks + state.completedTasks).find { it.id == taskId } + } + + /** + * Runs [write] under [subtaskMutex], then tells the screen what it did. The snackbars wait + * for one another, so their flows can suspend: sent inside the lock, a backlog would hold + * every later sub-task write, the sheet's load and the background save behind it. + */ + private fun launchSubtaskWrite(write: suspend PendingEvents.() -> Unit) { + viewModelScope.launch { + val events = PendingEvents() + subtaskMutex.withLock { events.write() } + events.events.forEach { send(it) } } } - private fun launchSubtaskWrite(write: suspend () -> Unit) { - viewModelScope.launch { subtaskMutex.withLock { write() } } + private suspend fun send(event: PendingEvent) { + when (event) { + is PendingEvent.Failure -> _operationFailures.emit(event.failure) + is PendingEvent.Deleted -> _subtaskDeletions.emit(event.subtask) + is PendingEvent.Offer -> _completionOffers.emit(event.taskId) + is PendingEvent.Then -> event.action() + } } fun showAddTaskDialog() { @@ -385,6 +436,7 @@ class TasksViewModel @Inject constructor( subtasksJob?.cancel() _editingTask.value = null _editingSubtasks.value = emptyList() + checklist = SubtaskListState(emptyList()) _showAddTaskDialog.value = true } @@ -395,11 +447,16 @@ class TasksViewModel @Inject constructor( // Unknown until loaded: a sheet saved before then leaves the stored checklist alone, // and one that fails to load never overwrites it with an empty list. _editingSubtasks.value = null + val sheet = SubtaskListState(null).also { checklist = it } _showAddTaskDialog.value = true subtasksJob = viewModelScope.launch { // After any list write still in flight, so the sheet shows it. subtaskMutex.withLock { tasksRepository.getSubtasks(task.id) } - .onSuccess { _editingSubtasks.value = SubtaskDrafts.fromSubtasks(it) } + .onSuccess { + val drafts = SubtaskDrafts.fromSubtasks(it) + _editingSubtasks.value = drafts + sheet.load(drafts) + } } } @@ -408,6 +465,7 @@ class TasksViewModel @Inject constructor( _showAddTaskDialog.value = false _editingTask.value = null _editingSubtasks.value = emptyList() + checklist = SubtaskListState(emptyList()) } fun addTask( @@ -500,20 +558,40 @@ class TasksViewModel @Inject constructor( fun saveDraftForBackground(draft: TaskDraft) { if (draft.title.isBlank() || !_showAddTaskDialog.value) return viewModelScope.launch { - draftMutex.withLock { persistDraft(draft) } + val events = PendingEvents() + draftMutex.withLock { events.persistDraft(draft) } + events.events.forEach { send(it) } } } - private suspend fun persistDraft(draft: TaskDraft) { + /** + * Stores the draft, then makes the sheet match what was stored: the rows it saved take + * their ids (and uids), so the next save, background or Save, updates them instead of + * adding them again, and [editingSubtasks] is the stored checklist. + */ + private suspend fun PendingEvents.persistDraft(draft: TaskDraft) { + val sheet = checklist val taskId = persistDraftTask(draft) ?: return val subtasks = draft.subtasks ?: return + val stored = storeDraftSubtasks(taskId, subtasks) + // Only while the sheet that was saved is still the one open on this task. + if (stored != null && sheet === checklist && _editingTask.value?.id == taskId) { + _editingSubtasks.value = SubtaskDrafts.fromSubtasks(stored) + sheet.adoptStored(SubtaskDrafts.storedRows(subtasks, stored)) + } + } + + /** Stores the draft's checklist; the stored rows, or null when the write failed. */ + private suspend fun PendingEvents.storeDraftSubtasks(taskId: Long, subtasks: List): List? { if (tasksRepository.saveSubtasks(taskId, SubtaskDrafts.toSubtasks(taskId, subtasks)).isFailure) { - _operationFailures.emit(TaskOperationFailure.UPDATE) + failed(TaskOperationFailure.UPDATE) + return null } + return tasksRepository.getSubtasks(taskId).getOrNull() } /** Inserts or updates the draft's task; its id, or null when the write failed. */ - private suspend fun persistDraftTask(draft: TaskDraft): Long? { + private suspend fun PendingEvents.persistDraftTask(draft: TaskDraft): Long? { val description = draft.description.ifBlank { null } val editing = _editingTask.value if (editing == null) { @@ -524,7 +602,7 @@ class TasksViewModel @Inject constructor( ) return tasksRepository.insertTask(task) .onSuccess { id -> _editingTask.value = task.copy(id = id) } - .onFailure { _operationFailures.emit(TaskOperationFailure.CREATE) } + .onFailure { failed(TaskOperationFailure.CREATE) } .getOrNull() } val updated = editing.copy( @@ -533,7 +611,7 @@ class TasksViewModel @Inject constructor( priority = draft.priority, ) val saved = updated == editing || tasksRepository.updateTask(updated).isSuccess - if (saved) _editingTask.value = updated else _operationFailures.emit(TaskOperationFailure.UPDATE) + if (saved) _editingTask.value = updated else failed(TaskOperationFailure.UPDATE) return editing.id.takeIf { saved } } @@ -552,19 +630,18 @@ class TasksViewModel @Inject constructor( */ fun toggleSubtask(subtask: Subtask, done: Boolean) { commitInlineEdit() - viewModelScope.launch { - subtaskMutex.withLock { - if (tasksRepository.setSubtaskCompleted(subtask.id, done).isFailure) { - _operationFailures.emit(TaskOperationFailure.STATUS_UPDATE) - return@withLock - } - val after = tasksRepository.getSubtasks(subtask.taskId).getOrNull() ?: return@withLock - val before = after.map { if (it.id == subtask.id) it.copy(isCompleted = !done) else it } - val state = uiState.value - val task = (state.activeTasks + state.completedTasks).find { it.id == subtask.taskId } - if (task != null && SubtaskDrafts.offersCompletion(task.isCompleted, before, after)) { - _completionOffers.emit(task.id) - } + launchSubtaskWrite { + val before = tasksRepository.getSubtasks(subtask.taskId).getOrNull() + // Already stored that way (a second tap of a double tap): nothing to save or offer. + if (before?.find { it.id == subtask.id }?.isCompleted == done) return@launchSubtaskWrite + if (tasksRepository.setSubtaskCompleted(subtask.id, done).isFailure) { + failed(TaskOperationFailure.STATUS_UPDATE) + return@launchSubtaskWrite + } + val after = tasksRepository.getSubtasks(subtask.taskId).getOrNull() ?: return@launchSubtaskWrite + val task = findTask(subtask.taskId) + if (task != null && before != null && SubtaskDrafts.offersCompletion(task.isCompleted, before, after)) { + events += PendingEvent.Offer(task.id) } } } @@ -575,6 +652,7 @@ class TasksViewModel @Inject constructor( val subtasks = tasksRepository.getSubtasks(task.id).getOrDefault(emptyList()) if (tasksRepository.deleteTaskById(task.id).isSuccess) { deletedSubtasks[task.id] = subtasks + forgetTrees(setOf(task.id)) hideAddTaskDialog() _deletedTasks.emit(task) } else { @@ -597,7 +675,9 @@ class TasksViewModel @Inject constructor( fun clearCompletedTasks(onSuccess: () -> Unit) { viewModelScope.launch { + val completed = uiState.value.completedTasks.mapTo(HashSet()) { it.id } if (tasksRepository.deleteAllCompletedTasks().isSuccess) { + forgetTrees(completed) onSuccess() } else { _operationFailures.emit(TaskOperationFailure.CLEAR_COMPLETED) @@ -605,8 +685,44 @@ class TasksViewModel @Inject constructor( } } + private fun forgetTreesOtherThan(state: TasksUiState) { + val ids = (state.activeTasks + state.completedTasks).mapTo(HashSet()) { it.id } + val expanded = _expandedTaskIds.value + if (!ids.containsAll(expanded)) setExpanded(expanded.filterTo(LinkedHashSet()) { it in ids }) + } + + private fun forgetTrees(taskIds: Set) { + val expanded = _expandedTaskIds.value + if (expanded.any { it in taskIds }) setExpanded(expanded.filterNotTo(LinkedHashSet()) { it in taskIds }) + } + fun onFilterSelected(filter: TaskFilter) { commitInlineEdit() _uiState.value = TaskFilterEngine.select(_uiState.value, filter) } } + +/** What a write tells the screen once it has left its lock (see launchSubtaskWrite). */ +private sealed interface PendingEvent { + data class Failure(val failure: TaskOperationFailure) : PendingEvent + + data class Deleted(val subtask: Subtask) : PendingEvent + + data class Offer(val taskId: Long) : PendingEvent + + /** A callback of the caller's, such as resetting a tree whose move was not stored. */ + class Then(val action: () -> Unit) : PendingEvent +} + +/** The events one write collects while it holds its lock. */ +private class PendingEvents { + val events = mutableListOf() + + fun failed(failure: TaskOperationFailure) { + events += PendingEvent.Failure(failure) + } + + fun later(action: () -> Unit) { + events += PendingEvent.Then(action) + } +} diff --git a/app/src/test/java/com/pasich/encly/presentation/viewmodel/TasksViewModelInlineSubtasksTest.kt b/app/src/test/java/com/pasich/encly/presentation/viewmodel/TasksViewModelInlineSubtasksTest.kt index c24c911..19c2dcc 100644 --- a/app/src/test/java/com/pasich/encly/presentation/viewmodel/TasksViewModelInlineSubtasksTest.kt +++ b/app/src/test/java/com/pasich/encly/presentation/viewmodel/TasksViewModelInlineSubtasksTest.kt @@ -99,7 +99,7 @@ class TasksViewModelInlineSubtasksTest { assertEquals(setOf(1L), viewModel.expandedTaskIds.value) assertEquals(longArrayOf(1L).toList(), savedState.get("expandedTaskIds")?.toList()) - // Ids restored from the saved state of tasks that no longer exist are dropped too. + // Ids restored from the saved state of tasks that no longer exist are dropped once loaded. savedState["expandedTaskIds"] = longArrayOf(1L, 99L) val recreated = newViewModel() advanceUntilIdle() @@ -127,15 +127,78 @@ class TasksViewModelInlineSubtasksTest { @Test fun movingASubtaskStoresTheNewOrderKeepingItsIdentity() = runTest(dispatcher) { advanceUntilIdle() + var resets = 0 - viewModel.moveSubtask(1, from = 2, to = 0) + // Bread into Milk's place. + viewModel.moveSubtask(1, movedId = 12, targetId = 10) { resets++ } advanceUntilIdle() assertEquals(listOf("Bread", "Milk", "Eggs"), shop.map { it.title }) assertEquals(listOf("u12", "u10", "u11"), shop.map { it.uid }) - viewModel.moveSubtask(1, from = 0, to = 5) + assertEquals(0, resets) + } + + @Test + fun aMoveAfterADeleteMovesTheRowThatWasDraggedOrNothing() = runTest(dispatcher) { advanceUntilIdle() - assertEquals(listOf("Bread", "Milk", "Eggs"), shop.map { it.title }) + // The tree still shows Milk, Eggs, Bread; Milk's delete is already stored. + repository.subtasks.value = repository.subtasks.value.filterNot { it.id == 10L } + var resets = 0 + + // Bread dragged onto Eggs: positions 2 -> 1 on screen, which no longer match the store. + viewModel.moveSubtask(1, movedId = 12, targetId = 11) { resets++ } + advanceUntilIdle() + assertEquals(listOf("Bread", "Eggs"), shop.map { it.title }) + + // Onto the deleted row: nothing moves, and the tree is told to show the stored order. + viewModel.moveSubtask(1, movedId = 11, targetId = 10) { resets++ } + advanceUntilIdle() + assertEquals(listOf("Bread", "Eggs"), shop.map { it.title }) + assertEquals(1, resets) + } + + @Test + fun aMoveThatIsNotStoredIsReportedAndResetsTheTree() = runTest(dispatcher) { + val failures = collect { viewModel.operationFailures.collect(it::add) } + advanceUntilIdle() + repository.failSubtasks = true + var resets = 0 + + viewModel.moveSubtask(1, movedId = 12, targetId = 10) { resets++ } + advanceUntilIdle() + + assertEquals(listOf("Milk", "Eggs", "Bread"), shop.map { it.title }) + assertEquals(listOf(TaskOperationFailure.UPDATE), failures) + assertEquals(1, resets) + } + + @Test + fun completingOrReopeningATaskKeepsItsTreeOpen() = runTest(dispatcher) { + advanceUntilIdle() + viewModel.toggleSubtasks(1) + val shopTask = repository.tasks.value.single { it.id == 1L } + + // The lists arrive one by one: for a moment the task is in neither. + repository.tasks.value = repository.tasks.value.filterNot { it.id == 1L } + advanceUntilIdle() + repository.tasks.value += shopTask.copy(isCompleted = true, completedDate = 1L) + advanceUntilIdle() + + assertEquals(setOf(1L), viewModel.expandedTaskIds.value) + assertEquals(longArrayOf(1L).toList(), savedState.get("expandedTaskIds")?.toList()) + } + + @Test + fun clearingTheDoneTasksForgetsTheirTrees() = runTest(dispatcher) { + repository.tasks.value = repository.tasks.value.map { if (it.id == 2L) it.copy(isCompleted = true) else it } + advanceUntilIdle() + viewModel.toggleSubtasks(1) + viewModel.toggleSubtasks(2) + + viewModel.clearCompletedTasks {} + advanceUntilIdle() + + assertEquals(setOf(1L), viewModel.expandedTaskIds.value) } @Test diff --git a/app/src/test/java/com/pasich/encly/presentation/viewmodel/TasksViewModelSubtasksTest.kt b/app/src/test/java/com/pasich/encly/presentation/viewmodel/TasksViewModelSubtasksTest.kt index 051e108..9727b86 100644 --- a/app/src/test/java/com/pasich/encly/presentation/viewmodel/TasksViewModelSubtasksTest.kt +++ b/app/src/test/java/com/pasich/encly/presentation/viewmodel/TasksViewModelSubtasksTest.kt @@ -3,7 +3,9 @@ package com.pasich.encly.presentation.viewmodel import com.pasich.encly.data.model.Subtask import com.pasich.encly.data.model.Task import com.pasich.encly.domain.usecase.task.UpdateTaskStatusUseCase +import com.pasich.encly.presentation.dialogs.tasks.SubtaskListState import com.pasich.encly.testutil.TestTasksRepository +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.launch @@ -18,6 +20,7 @@ import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNull +import org.junit.Assert.assertSame import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test @@ -258,6 +261,119 @@ class TasksViewModelSubtasksTest { assertEquals(listOf("Tickets"), repository.subtasks.value.filter { it.taskId == trip.id }.map { it.title }) } + @Test + fun aRotationAfterABackgroundSaveKeepsTheNewTasksSheetAsItWas() = runTest(dispatcher) { + // The sheet's checklist lives in the ViewModel, so the recreated sheet gets the same one. + viewModel.showAddTaskDialog() + val sheet = viewModel.checklist + sheet.addRow("Tickets") + sheet.addRow("Hotel") + sheet.changeNewTitle("Pass") + + // Rotating pauses the activity: the sheet is saved in the background. + viewModel.saveDraftForBackground(TaskDraft("Trip", "", 0, sheet.toSave())) + advanceUntilIdle() + val trip = repository.tasks.value.single { it.title == "Trip" } + val flushed = repository.subtasks.value.filter { it.taskId == trip.id } + assertEquals(listOf("Tickets", "Hotel", "Pass"), flushed.map { it.title }) + assertEquals(SubtaskDrafts.fromSubtasks(flushed), viewModel.editingSubtasks.value) + + // The recreated sheet shows what was there; the user goes on editing, then saves. + assertSame(sheet, viewModel.checklist) + assertEquals(listOf("Tickets", "Hotel"), sheet.subtasks?.map { it.title }) + sheet.remove(sheet.subtasks!!.first { it.title == "Tickets" }.key) + sheet.setChecked(sheet.subtasks!!.first { it.title == "Hotel" }.key, true) + viewModel.editTask(trip.id, "Trip", null, priority = 0, subtasks = sheet.toSave()) + advanceUntilIdle() + + val rows = repository.subtasks.value.filter { it.taskId == trip.id }.sortedBy { it.position } + assertEquals(listOf("Hotel", "Pass"), rows.map { it.title }) + assertTrue(rows.first().isCompleted) + // The row the background save stored is updated, not stored again. + assertEquals(flushed.single { it.title == "Hotel" }.uid, rows.first().uid) + } + + @Test + fun aRotationAfterABackgroundSaveKeepsAnEditedTasksSheetAsItWas() = runTest(dispatcher) { + viewModel.showEditTaskDialog(repository.tasks.value.first()) + advanceUntilIdle() + val sheet = viewModel.checklist + sheet.setTitle(10, "Oat milk") + sheet.addRow("Bread") + + viewModel.saveDraftForBackground(TaskDraft("Shop", "", 0, sheet.toSave())) + advanceUntilIdle() + val bread = repository.subtasks.value.single { it.title == "Bread" } + assertEquals(bread.id, sheet.subtasks!!.single { it.title == "Bread" }.id) + + // After the rotation: a deleted row stays deleted, a renamed one renamed, a tick kept. + sheet.remove(11) + sheet.setChecked(sheet.subtasks!!.single { it.title == "Bread" }.key, true) + viewModel.editTask(1, "Shop", null, priority = 0, subtasks = sheet.toSave()) + advanceUntilIdle() + + val rows = repository.subtasks.value.filter { it.taskId == 1L }.sortedBy { it.position } + assertEquals(listOf("Oat milk", "Bread"), rows.map { it.title }) + assertEquals(listOf("u10", bread.uid), rows.map { it.uid }) + assertEquals(listOf(true, true), rows.map { it.isCompleted }) + } + + @Test + fun aDoubleTapOnTheLastOpenCheckboxOffersToCompleteOnce() = runTest(dispatcher) { + val offers = collectOffers() + advanceUntilIdle() + val eggs = viewModel.uiState.value.subtasks.getValue(1L)[1] + + // Both taps come from the same row, still drawn open. + viewModel.toggleSubtask(eggs, done = true) + viewModel.toggleSubtask(eggs, done = true) + advanceUntilIdle() + + assertEquals(listOf(1L), offers) + assertTrue(repository.subtasks.value.single { it.id == 11L }.isCompleted) + } + + @Test + fun snackbarsWaitingToBeShownDoNotHoldUpSubtaskWrites() = runTest(dispatcher) { + repository.subtasks.value += listOf( + Subtask(id = 12, taskId = 1, title = "Bread", position = 2, uid = "u12"), + Subtask(id = 13, taskId = 1, title = "Jam", position = 3, uid = "u13"), + ) + // The screen shows one "Sub-task deleted" snackbar at a time: the first one never ends here. + val shown = mutableListOf() + val dismissed = CompletableDeferred() + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + viewModel.subtaskDeletions.collect { + shown += it + dismissed.await() + } + } + advanceUntilIdle() + listOf(11L, 12L, 13L).forEach { id -> + viewModel.startRenamingSubtask(repository.subtasks.value.single { it.id == id }) + viewModel.deleteInlineSubtask() + } + advanceUntilIdle() + + // A tick and the sheet's load still go through while the Undo offers wait. + viewModel.toggleSubtask(repository.subtasks.value.single { it.id == 10L }, done = false) + viewModel.showEditTaskDialog(repository.tasks.value.first()) + advanceUntilIdle() + assertFalse(repository.subtasks.value.single { it.id == 10L }.isCompleted) + assertEquals(listOf("Milk"), viewModel.editingSubtasks.value?.map { it.title }) + + // None of the Undo offers is dropped. + dismissed.complete(Unit) + advanceUntilIdle() + assertEquals(listOf(11L, 12L, 13L), shown.map { it.id }) + } + + /** Types [title] into the sheet's "add" field and adds it. */ + private fun SubtaskListState.addRow(title: String) { + changeNewTitle(title) + add() + } + private fun TestScope.collectOffers(): List { val offers = mutableListOf() backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { diff --git a/app/src/test/java/com/pasich/encly/ui/screens/Fakes.kt b/app/src/test/java/com/pasich/encly/ui/screens/Fakes.kt index 8f7104a..4dc7a88 100644 --- a/app/src/test/java/com/pasich/encly/ui/screens/Fakes.kt +++ b/app/src/test/java/com/pasich/encly/ui/screens/Fakes.kt @@ -159,9 +159,13 @@ internal class FakeTasksRepository(initial: List = emptyList()) : TasksRep } override suspend fun getSubtasks(taskId: Long): Result> = - Result.success(subtasks.value.filter { it.taskId == taskId }) + Result.success(subtasks.value.filter { it.taskId == taskId }.sortedBy { it.position }) - override suspend fun saveSubtasks(taskId: Long, subtasks: List): Result = Result.success(Unit) + override suspend fun saveSubtasks(taskId: Long, subtasks: List): Result { + val saved = subtasks.mapIndexed { index, row -> row.copy(taskId = taskId, position = index) } + this.subtasks.update { list -> list.filterNot { it.taskId == taskId } + saved } + return Result.success(Unit) + } override suspend fun restoreTask(task: Task, subtasks: List): Result { tasks.update { it + task } diff --git a/app/src/test/java/com/pasich/encly/ui/screens/TasksScreenRecreationTest.kt b/app/src/test/java/com/pasich/encly/ui/screens/TasksScreenRecreationTest.kt new file mode 100644 index 0000000..36d8944 --- /dev/null +++ b/app/src/test/java/com/pasich/encly/ui/screens/TasksScreenRecreationTest.kt @@ -0,0 +1,114 @@ +package com.pasich.encly.ui.screens + +import android.app.Application +import android.content.Context +import android.content.pm.ActivityInfo +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.ui.test.junit4.createEmptyComposeRule +import androidx.lifecycle.Lifecycle +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import androidx.test.core.app.ActivityScenario +import androidx.test.core.app.ApplicationProvider +import com.pasich.encly.data.model.Task +import com.pasich.encly.domain.model.ThemeSettings +import com.pasich.encly.presentation.navigation.NavRoutes +import com.pasich.encly.presentation.screen.TasksScreen +import com.pasich.encly.presentation.viewmodel.InlineSubtaskTarget +import com.pasich.encly.presentation.viewmodel.TasksViewModel +import com.pasich.encly.ui.theme.EnclyTheme +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config + +/** + * The Tasks screen in a real activity that is recreated (a rotation) or sent to the background + * while a new sub-task is being typed into the list's inline field. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35], application = Application::class, qualifiers = "w411dp-h891dp-xxhdpi") +class TasksScreenRecreationTest { + @get:Rule + val rule = createEmptyComposeRule() + + private val context: Context get() = ApplicationProvider.getApplicationContext() + private val app by lazy { TestApp(context) } + private lateinit var tasks: TasksViewModel + private var scenario: ActivityScenario? = null + + @Before + fun setUp() { + app.withTasks(Task(id = 1, title = "Shop")) + tasks = app.tasksVm() + TasksHostActivity.viewModels = FakeViewModels(tasks) + shadowOf(context.packageManager).addOrUpdateActivity( + ActivityInfo().apply { + name = TasksHostActivity::class.java.name + packageName = context.packageName + }, + ) + scenario = ActivityScenario.launch(TasksHostActivity::class.java) + rule.waitForIdle() + rule.runOnIdle { + tasks.startAddingSubtask(1) + tasks.onInlineTextChange("Mil") + } + rule.waitForIdle() + } + + @After + fun tearDown() { + scenario?.close() + TasksHostActivity.viewModels?.viewModelStore?.clear() + TasksHostActivity.viewModels = null + } + + @Test + fun aRotationKeepsTheTypedSubtaskInItsField() { + checkNotNull(scenario).recreate() + rule.waitForIdle() + + // Nothing half-typed was saved, and the field is still open with what was typed. + assertTrue(app.tasks.subtasks.value.isEmpty()) + assertEquals(InlineSubtaskTarget.Add(1), tasks.inlineEdit?.target) + assertEquals("Mil", tasks.inlineEdit?.text) + } + + @Test + fun leavingTheAppSavesTheTypedSubtask() { + checkNotNull(scenario).moveToState(Lifecycle.State.CREATED) + rule.waitForIdle() + + assertEquals(listOf("Mil"), app.tasks.subtasks.value.map { it.title }) + } +} + +/** Hosts [TasksScreen] with ViewModels that outlive the activity, as the back stack keeps them. */ +class TasksHostActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val owner = checkNotNull(viewModels) + setContent { + EnclyTheme(settings = ThemeSettings(), dark = false) { + val nav = rememberNavController() + NavHost(navController = nav, startDestination = NavRoutes.TasksRoute.name) { + composable(NavRoutes.TasksRoute.name) { ProvideViewModels(owner) { TasksScreen(nav) } } + } + } + } + } + + companion object { + var viewModels: FakeViewModels? = null + } +} diff --git a/app/src/test/java/com/pasich/encly/ui/screens/TasksScreenTest.kt b/app/src/test/java/com/pasich/encly/ui/screens/TasksScreenTest.kt index 6e9063e..84185d0 100644 --- a/app/src/test/java/com/pasich/encly/ui/screens/TasksScreenTest.kt +++ b/app/src/test/java/com/pasich/encly/ui/screens/TasksScreenTest.kt @@ -8,10 +8,14 @@ import androidx.compose.ui.semantics.SemanticsProperties import androidx.compose.ui.semantics.getOrNull import androidx.compose.ui.state.ToggleableState import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.SemanticsNodeInteraction import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsFocused import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.hasClickAction import androidx.compose.ui.test.hasSetTextAction +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.onAllNodesWithContentDescription import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onFirst import androidx.compose.ui.test.onNodeWithContentDescription @@ -310,6 +314,43 @@ class TasksScreenTest : ComposeScreenTest() { waitForText("Milk") } + @Test + fun moveUpAndDownAreOnTheTitleTalkBackFocusesInTheTree() { + withShoppingList() + show() + waitForText("Shop") + rule.onNodeWithText("Shop").performClick() + waitForText("Milk") + + val milk = rule.onNode(hasText("Milk") and hasClickAction()) + val eggs = rule.onNode(hasText("Eggs") and hasClickAction()) + assertEquals(listOf(str(R.string.tag_move_down)), customActions(milk)) + assertEquals(listOf(str(R.string.tag_move_up)), customActions(eggs)) + + // And they move the row. + rule.runOnIdle { + milk.fetchSemanticsNode().config[SemanticsActions.CustomActions].single().action() + } + waitFor { app.tasks.subtasks.value.sortedBy { it.position }.map { it.title } == listOf("Eggs", "Milk") } + } + + @Test + fun moveUpAndDownAreOnTheDragHandlesInTheSheet() { + withShoppingList() + show() + waitForText("Shop") + rule.onNodeWithContentDescription(str(R.string.task_edit_placeholder)).performClick() + waitForText(str(R.string.task_delete)) + waitForText("Milk") + + val handles = rule.onAllNodesWithContentDescription(str(R.string.tag_drag_handle)) + assertEquals(listOf(str(R.string.tag_move_down)), customActions(handles[0])) + assertEquals(listOf(str(R.string.tag_move_up)), customActions(handles[1])) + } + + private fun customActions(node: SemanticsNodeInteraction): List = + node.fetchSemanticsNode().config.getOrNull(SemanticsActions.CustomActions).orEmpty().map { it.label } + private fun withShoppingList(vararg more: Subtask) { app.withTasks(Task(id = 1, title = "Shop")) app.tasks.subtasks.value = listOf( From d782941cba75f22b376b271323e223ae94d2d68a Mon Sep 17 00:00:00 2001 From: pasichdev Date: Mon, 5 Oct 2026 13:04:21 +0300 Subject: [PATCH 14/19] fix(handoff): refuse a hand-off another app passed on in My Notes' name The hand-off trusted the calling package and its certificate. That package names the app the result goes to, not the app that wrote the intent: My Notes starts a file picker for a result in its own import, and a malicious picker could forward that request into Encly with FLAG_ACTIVITY_FORWARD_RESULT. Encly would then see My Notes as the caller of the picker's intent and read the picker's URI. Before the URI is read, the activity now also refuses a forwarded request, an app other than My Notes where Android 14+ names the launching app, and a URI that is not from My Notes' own FileProvider (its authority, and a provider that PackageManager finds in the My Notes package). The package and authority are defined together in MyNotesCallerVerifier. SECURITY.md no longer claims the caller cannot be forged and lists the checks. Robolectric tests cover each refusal. Refs: #46 --- SECURITY.md | 22 +++-- .../pasich/encly/ImportFromMyNotesActivity.kt | 37 +++++--- .../encly/data/handoff/HandoffRequest.kt | 56 ++++++++++++ .../data/handoff/MyNotesCallerVerifier.kt | 10 ++- .../encly/ImportFromMyNotesActivityTest.kt | 22 ++++- .../encly/data/handoff/HandoffRequestTest.kt | 88 +++++++++++++++++++ 6 files changed, 213 insertions(+), 22 deletions(-) create mode 100644 app/src/main/java/com/pasich/encly/data/handoff/HandoffRequest.kt create mode 100644 app/src/test/java/com/pasich/encly/data/handoff/HandoffRequestTest.kt diff --git a/SECURITY.md b/SECURITY.md index 914e3ab..0f9c0b3 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -281,12 +281,22 @@ Encly is the successor of My Notes, which can hand its data over once counts, and exposes no provider or anything readable. - `ImportFromMyNotesActivity` is exported for the action - `com.pasich.encly.action.IMPORT_FROM_MY_NOTES` only. Before it looks at the intent's data it - requires `getCallingPackage() == "com.pasich.mynotes"` (set by the system for - `startActivityForResult`) **and** a signing certificate from a pinned SHA-256 set - (`MyNotesCallerVerifier.TRUSTED_MY_NOTES_CERT_SHA256`): `hasSigningCertificate(…, - CERT_INPUT_SHA256)` on Android 9+, and on Android 8.x `GET_SIGNATURES` with exactly one signer. - Anything else is refused (`untrusted_caller`) without reading the URI. + `com.pasich.encly.action.IMPORT_FROM_MY_NOTES` only. Before it reads the intent's URI it + requires (`HandoffRequest`): + - `getCallingPackage() == "com.pasich.mynotes"` **and** a signing certificate from a pinned + SHA-256 set (`MyNotesCallerVerifier.TRUSTED_MY_NOTES_CERT_SHA256`): `hasSigningCertificate(…, + CERT_INPUT_SHA256)` on Android 9+, and on Android 8.x `GET_SIGNATURES` with exactly one signer; + - no `FLAG_ACTIVITY_FORWARD_RESULT`. The calling package names the app the result goes to, not + the one that wrote the intent: an app My Notes starts for a result (the file picker of its + own import, say) could forward that request here, and the system would report My Notes as + the caller of that app's intent and URI; + - on Android 14+, where the system names the app that launched the activity + (`getLaunchedFromPackage`, only when that app shares its identity), that it is My Notes; + - a `content://` URI of My Notes' own FileProvider: the authority `com.pasich.mynotes.provider`, + which `PackageManager.resolveContentProvider` must find in the My Notes package. + + Anything else is refused (`untrusted_caller`, or `invalid_payload` for a wrong action or a + URI that is not `content://`) without reading the URI. - The vault must be unlocked through the normal lock screen first; an open session (within the auto-lock grace) is used as is. Backgrounding locks it as everywhere else. - My Notes' ZIP (plaintext) is copied to Encly's private cache, because the URI grant ends with diff --git a/app/src/main/java/com/pasich/encly/ImportFromMyNotesActivity.kt b/app/src/main/java/com/pasich/encly/ImportFromMyNotesActivity.kt index 8009ee3..337e80b 100644 --- a/app/src/main/java/com/pasich/encly/ImportFromMyNotesActivity.kt +++ b/app/src/main/java/com/pasich/encly/ImportFromMyNotesActivity.kt @@ -1,7 +1,7 @@ package com.pasich.encly -import android.content.ContentResolver import android.content.Intent +import android.os.Build import android.os.Bundle import android.view.WindowManager import androidx.activity.compose.setContent @@ -25,6 +25,8 @@ import com.pasich.encly.core.security.KeyboardPrivacy import com.pasich.encly.core.security.SecurityManager import com.pasich.encly.data.handoff.AndroidPackageSignatures import com.pasich.encly.data.handoff.HandoffError +import com.pasich.encly.data.handoff.HandoffLaunch +import com.pasich.encly.data.handoff.HandoffRequest import com.pasich.encly.data.handoff.MyNotesCallerVerifier import com.pasich.encly.domain.repository.SettingsRepository import com.pasich.encly.presentation.components.SecureTextInputBoundary @@ -46,8 +48,9 @@ import javax.inject.Inject * grants read access to. Encly never sends anything back except the result counts. * * Order, each step only after the one before succeeded: - * 1. the caller must be My Notes, signed with a pinned certificate ([MyNotesCallerVerifier]); - * anything else is refused before the URI is even looked at; + * 1. the caller must be My Notes, signed with a pinned certificate ([MyNotesCallerVerifier]), + * not passing on another app's request, and the URI from My Notes' own FileProvider + * ([HandoffRequest]); anything else is refused before the URI is read; * 2. the vault is unlocked through the normal lock screen; * 3. the ZIP is copied, read and deleted, and only counts are shown; * 4. the import runs when the user confirms. @@ -76,14 +79,12 @@ class ImportFromMyNotesActivity : AppCompatActivity() { super.onCreate(savedInstanceState) protectWindow() - val verifier = MyNotesCallerVerifier(AndroidPackageSignatures(packageManager)) - if (!verifier.isTrusted(callingPackage)) { - finishWith(HandoffError.UNTRUSTED_CALLER) - return + val refused = HandoffRequest.check(launch()) { authority -> + packageManager.resolveContentProvider(authority, 0)?.packageName } val uri = intent?.data - if (intent?.action != ACTION_IMPORT_FROM_MY_NOTES || uri?.scheme != ContentResolver.SCHEME_CONTENT) { - finishWith(HandoffError.INVALID_PAYLOAD) + if (refused != null || uri == null) { + finishWith(refused ?: HandoffError.INVALID_PAYLOAD) return } // Leaving before the end (Back, the close button) reports a cancellation. @@ -125,6 +126,22 @@ class ImportFromMyNotesActivity : AppCompatActivity() { } } + /** How this activity was started, for [HandoffRequest.check]. */ + private fun launch(): HandoffLaunch { + val verifier = MyNotesCallerVerifier(AndroidPackageSignatures(packageManager)) + return HandoffLaunch( + flags = intent?.flags ?: 0, + callerTrusted = { verifier.isTrusted(callingPackage) }, + launchedFromPackage = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + launchedFromPackage + } else { + null + }, + action = intent?.action, + uri = intent?.data, + ) + } + /** Keeps the activity result in step with the hand-off, so any way out reports it. */ private fun publishResult(step: HandoffStep) { when (step) { @@ -151,7 +168,7 @@ class ImportFromMyNotesActivity : AppCompatActivity() { private fun reasonIntent(error: HandoffError) = Intent().putExtra(EXTRA_REASON, error.reason) companion object { - const val ACTION_IMPORT_FROM_MY_NOTES = "com.pasich.encly.action.IMPORT_FROM_MY_NOTES" + const val ACTION_IMPORT_FROM_MY_NOTES = HandoffRequest.ACTION_IMPORT_FROM_MY_NOTES const val EXTRA_NOTES = "notes" const val EXTRA_TASKS = "tasks" const val EXTRA_TAGS = "tags" diff --git a/app/src/main/java/com/pasich/encly/data/handoff/HandoffRequest.kt b/app/src/main/java/com/pasich/encly/data/handoff/HandoffRequest.kt new file mode 100644 index 0000000..7b26c10 --- /dev/null +++ b/app/src/main/java/com/pasich/encly/data/handoff/HandoffRequest.kt @@ -0,0 +1,56 @@ +package com.pasich.encly.data.handoff + +import android.content.ContentResolver +import android.content.Intent +import android.net.Uri + +/** + * How the hand-off activity was started: what [HandoffRequest.check] looks at before the URI is + * read. [callerTrusted] asks [MyNotesCallerVerifier] (only once the cheaper checks passed). + * [launchedFromPackage] is `Activity.getLaunchedFromPackage()` (API 34+), null where the system + * does not say (it only does when the launching app shares its identity). + */ +class HandoffLaunch( + val flags: Int, + val callerTrusted: () -> Boolean, + val launchedFromPackage: String?, + val action: String?, + val uri: Uri?, +) + +/** The checks of an incoming hand-off, in the order ImportFromMyNotesActivity runs them. */ +object HandoffRequest { + /** + * Null when the hand-off may go on, else why it is refused. [providerPackage] answers which + * package owns a content provider authority (PackageManager.resolveContentProvider). + * + * - `FLAG_ACTIVITY_FORWARD_RESULT`: another app passed on a request My Notes made of it (a + * file picker, say), so the calling package is My Notes but the intent is that app's; + * - the caller must be My Notes with a pinned certificate ([MyNotesCallerVerifier]), and, + * where the system names the app that launched this activity, that app too; + * - the action and a `content://` URI; + * - the URI from My Notes' own FileProvider: its authority, owned by the My Notes package. + */ + @Suppress("ReturnCount") // One gate per check, each refusing on its own. + fun check(launch: HandoffLaunch, providerPackage: (authority: String) -> String?): HandoffError? { + if (launch.flags and Intent.FLAG_ACTIVITY_FORWARD_RESULT != 0) return HandoffError.UNTRUSTED_CALLER + if (!launch.callerTrusted()) return HandoffError.UNTRUSTED_CALLER + val launchedFrom = launch.launchedFromPackage + if (launchedFrom != null && launchedFrom != MyNotesCallerVerifier.MY_NOTES_PACKAGE) { + return HandoffError.UNTRUSTED_CALLER + } + val uri = launch.uri + if (launch.action != ACTION_IMPORT_FROM_MY_NOTES || uri?.scheme != ContentResolver.SCHEME_CONTENT) { + return HandoffError.INVALID_PAYLOAD + } + val authority = uri.authority + if (authority != MyNotesCallerVerifier.MY_NOTES_AUTHORITY || + providerPackage(authority) != MyNotesCallerVerifier.MY_NOTES_PACKAGE + ) { + return HandoffError.UNTRUSTED_CALLER + } + return null + } + + const val ACTION_IMPORT_FROM_MY_NOTES = "com.pasich.encly.action.IMPORT_FROM_MY_NOTES" +} diff --git a/app/src/main/java/com/pasich/encly/data/handoff/MyNotesCallerVerifier.kt b/app/src/main/java/com/pasich/encly/data/handoff/MyNotesCallerVerifier.kt index eb5ea84..721a064 100644 --- a/app/src/main/java/com/pasich/encly/data/handoff/MyNotesCallerVerifier.kt +++ b/app/src/main/java/com/pasich/encly/data/handoff/MyNotesCallerVerifier.kt @@ -41,8 +41,11 @@ class AndroidPackageSignatures(private val packageManager: PackageManager) : Pac * is read: anything else is refused without touching its data. * * The calling package comes from `Activity.getCallingPackage()`, which the system fills in for - * `startActivityForResult` and a caller cannot forge; the signing certificate then proves that - * package is the real My Notes and not an app installed under its name. + * `startActivityForResult`; the signing certificate then proves that package is the real My + * Notes and not an app installed under its name. It names the app the result goes to, not the + * one that sent the intent: an activity My Notes started for a result can pass the request on + * with `FLAG_ACTIVITY_FORWARD_RESULT`, and then My Notes is the calling package of an intent + * another app wrote. [HandoffRequest] refuses that and checks where the URI comes from. */ class MyNotesCallerVerifier( private val signatures: PackageSignatures, @@ -69,6 +72,9 @@ class MyNotesCallerVerifier( const val MY_NOTES_PACKAGE = "com.pasich.mynotes" private const val HEX = 16 + /** The authority of My Notes' FileProvider, which the hand-off's URI must use. */ + const val MY_NOTES_AUTHORITY = "$MY_NOTES_PACKAGE.provider" + /** * SHA-256 (lower-case hex) of every certificate My Notes is signed with. A caller signed * with none of them is refused. diff --git a/app/src/test/java/com/pasich/encly/ImportFromMyNotesActivityTest.kt b/app/src/test/java/com/pasich/encly/ImportFromMyNotesActivityTest.kt index 4f30b38..0497bfa 100644 --- a/app/src/test/java/com/pasich/encly/ImportFromMyNotesActivityTest.kt +++ b/app/src/test/java/com/pasich/encly/ImportFromMyNotesActivityTest.kt @@ -6,6 +6,7 @@ import android.app.Application import android.content.Intent import android.net.Uri import androidx.test.core.app.ApplicationProvider +import com.pasich.encly.data.handoff.MyNotesCallerVerifier import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Before @@ -31,8 +32,8 @@ class ImportFromMyNotesActivityTest { shadowOf(application).grantPermissions(Manifest.permission.HIDE_OVERLAY_WINDOWS) } - private fun refusalOf(callingPackage: String?): Pair { - val controller = Robolectric.buildActivity(ImportFromMyNotesActivity::class.java, handoffIntent) + private fun refusalOf(callingPackage: String?, intent: Intent = handoffIntent): Pair { + val controller = Robolectric.buildActivity(ImportFromMyNotesActivity::class.java, intent) val shadow = shadowOf(controller.get()) shadow.setCallingPackage(callingPackage) controller.create() @@ -54,6 +55,19 @@ class ImportFromMyNotesActivityTest { assertEquals(Activity.RESULT_CANCELED to "untrusted_caller", refusalOf(null)) } - // My Notes' name with the wrong certificate: MyNotesCallerVerifierTest (Robolectric does not - // implement PackageManager.hasSigningCertificate). + @Test + fun aRequestAnotherAppPassedOnIsRefusedEvenInMyNotesName() { + // A picker that My Notes started for a result forwards that request into Encly: the system + // then reports My Notes as the caller of the picker's own intent and URI. + val forwarded = Intent(handoffIntent).addFlags(Intent.FLAG_ACTIVITY_FORWARD_RESULT) + + assertEquals( + Activity.RESULT_CANCELED to "untrusted_caller", + refusalOf(MyNotesCallerVerifier.MY_NOTES_PACKAGE, forwarded), + ) + } + + // Each check before the URI is read: HandoffRequestTest. My Notes' name with the wrong + // certificate: MyNotesCallerVerifierTest (Robolectric does not implement + // PackageManager.hasSigningCertificate). } diff --git a/app/src/test/java/com/pasich/encly/data/handoff/HandoffRequestTest.kt b/app/src/test/java/com/pasich/encly/data/handoff/HandoffRequestTest.kt new file mode 100644 index 0000000..0c87cd1 --- /dev/null +++ b/app/src/test/java/com/pasich/encly/data/handoff/HandoffRequestTest.kt @@ -0,0 +1,88 @@ +package com.pasich.encly.data.handoff + +import android.content.Intent +import android.net.Uri +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * The checks before a hand-off's URI is read (pasichDev/Encly#46): a request another app passed + * on, or a URI that is not My Notes' own, is refused even when the calling package is My Notes. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class HandoffRequestTest { + private val myNotes = MyNotesCallerVerifier.MY_NOTES_PACKAGE + private val myNotesUri = Uri.parse( + "content://${MyNotesCallerVerifier.MY_NOTES_AUTHORITY}/encly_handoff/handoff.zip", + ) + + /** Which package owns each provider authority on the device. */ + private val providers = mutableMapOf(MyNotesCallerVerifier.MY_NOTES_AUTHORITY to myNotes) + + private fun check( + flags: Int = Intent.FLAG_GRANT_READ_URI_PERMISSION, + callerTrusted: Boolean = true, + launchedFrom: String? = null, + action: String? = HandoffRequest.ACTION_IMPORT_FROM_MY_NOTES, + uri: Uri? = myNotesUri, + ): HandoffError? = HandoffRequest.check(HandoffLaunch(flags, { callerTrusted }, launchedFrom, action, uri)) { + providers[it] + } + + @Test + fun myNotesWithItsOwnProviderGoesOn() { + assertNull(check()) + assertNull(check(launchedFrom = myNotes)) + } + + @Test + fun aRequestPassedOnWithForwardResultIsRefused() { + // A picker My Notes started forwards the result request into Encly with its own intent. + assertEquals( + HandoffError.UNTRUSTED_CALLER, + check(flags = Intent.FLAG_ACTIVITY_FORWARD_RESULT or Intent.FLAG_GRANT_READ_URI_PERMISSION), + ) + } + + @Test + fun aCallerThatIsNotAVerifiedMyNotesIsRefused() { + assertEquals(HandoffError.UNTRUSTED_CALLER, check(callerTrusted = false)) + } + + @Test + fun anotherLaunchingAppIsRefusedWhereTheSystemNamesIt() { + assertEquals(HandoffError.UNTRUSTED_CALLER, check(launchedFrom = "com.example.picker")) + } + + @Test + fun aWrongActionOrAUriThatIsNotContentIsInvalid() { + assertEquals(HandoffError.INVALID_PAYLOAD, check(action = Intent.ACTION_VIEW)) + assertEquals(HandoffError.INVALID_PAYLOAD, check(uri = null)) + assertEquals(HandoffError.INVALID_PAYLOAD, check(uri = Uri.parse("file:///sdcard/handoff.zip"))) + } + + @Test + fun aUriFromAnotherProviderIsRefused() { + providers["com.example.files"] = "com.example.picker" + + assertEquals( + HandoffError.UNTRUSTED_CALLER, + check(uri = Uri.parse("content://com.example.files/handoff.zip")), + ) + } + + @Test + fun myNotesAuthorityServedByAnotherPackageIsRefused() { + // An app that registered My Notes' authority first (My Notes not installed or replaced). + providers[MyNotesCallerVerifier.MY_NOTES_AUTHORITY] = "com.example.squatter" + + assertEquals(HandoffError.UNTRUSTED_CALLER, check()) + providers.clear() + assertEquals(HandoffError.UNTRUSTED_CALLER, check()) + } +} From 4f4dfb4eb52d4e9014a9238189096c662b6a6ad7 Mon Sep 17 00:00:00 2001 From: pasichdev Date: Mon, 5 Oct 2026 13:24:04 +0300 Subject: [PATCH 15/19] test(security): wait for the wipe-PIN cleanup on its own scope The test of the cleanup that runs after a PIN unlock gave LockViewModel an unconfined scope. A launch on Dispatchers.Unconfined from inside another unconfined coroutine is queued until that one returns, so the test sometimes checked before the cleanup had run. It now uses a background scope and waits for the call, like the real application scope. Refs: #52 --- .../encly/presentation/viewmodel/LockViewModelTest.kt | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/app/src/test/java/com/pasich/encly/presentation/viewmodel/LockViewModelTest.kt b/app/src/test/java/com/pasich/encly/presentation/viewmodel/LockViewModelTest.kt index 42c017d..fd4f138 100644 --- a/app/src/test/java/com/pasich/encly/presentation/viewmodel/LockViewModelTest.kt +++ b/app/src/test/java/com/pasich/encly/presentation/viewmodel/LockViewModelTest.kt @@ -58,7 +58,7 @@ class LockViewModelTest { security = mock(SecurityManager::class.java) sessionLock = SessionLockManager(security) sessionLock.onStart(mock(LifecycleOwner::class.java)) - viewModel = LockViewModel(security, sessionLock, CoroutineScope(Dispatchers.Unconfined)) + viewModel = LockViewModel(security, sessionLock, CoroutineScope(Dispatchers.Default)) } @After @@ -84,7 +84,8 @@ class LockViewModelTest { `when`(security.unlockWithPin(PIN.toCharArray())).thenReturn(VaultUnlockResult.SUCCESS) assertEquals(PinUnlockResult.SUCCESS, pin()) - verify(security).completePendingWipe() + // On the application scope, off the unlock's path. + verify(security, timeout(WAIT_MS)).completePendingWipe() } @Test @@ -315,5 +316,6 @@ class LockViewModelTest { const val WORDS = "one two three four five six seven eight nine ten eleven twelve" const val KEY_LENGTH = 32 const val LOCKOUT_MS = 30_000L + const val WAIT_MS = 5_000L } } From 366bb01da2cfed5eae1463786c87a7b1ab3836be Mon Sep 17 00:00:00 2001 From: pasichdev Date: Mon, 5 Oct 2026 13:33:44 +0300 Subject: [PATCH 16/19] fix(handoff): clean up after killed hand-offs and keep a finished one finished Three smaller gaps in the My Notes hand-off: - My Notes' ZIP (plaintext) was staged under one fixed name and only deleted by the next hand-off, so a killed process left it in the cache until then, even across a wipe-PIN erase, and two hand-offs at once shared the file. Each load now copies into a file of its own and deletes only that one. The staging directory is swept at app start (off the main thread) and when a wipe-PIN erase finishes, never touching a file a load of the same process is reading. - When the lock screen found that nothing can open the vault any more, the failure was set but the screen kept showing the lock screen while the vault stayed locked. A failure now shows even while locked. - After process death on the Done page, the activity started over: the preview came back and a second confirm reported zeros. The counts are kept in the saved state, and a restored hand-off shows them again without reading the URI. SECURITY.md and PRIVACY.md say when the staged copy is deleted. Tests cover the per-load files and the sweep, the failure on the lock screen, the restored Done page and the sweep at the end of an erase. Refs: #46 --- PRIVACY.md | 4 +- SECURITY.md | 8 +- .../java/com/pasich/encly/MyApplication.kt | 13 ++ .../pasich/encly/core/di/SecurityModule.kt | 5 + .../encly/core/security/SecurityManager.kt | 5 + .../handoff/CacheHandoffStagingSweeper.kt | 14 ++ .../encly/data/handoff/HandoffStaging.kt | 56 ++++++-- .../screen/handoff/ImportFromMyNotesScreen.kt | 4 +- .../viewmodel/ImportFromMyNotesViewModel.kt | 47 ++++++- .../pasich/encly/core/security/WipePinTest.kt | 18 ++- .../encly/data/handoff/HandoffStagingTest.kt | 47 ++++++- .../ImportFromMyNotesViewModelTest.kt | 121 ++++++++++++++++++ .../ui/screens/ImportFromMyNotesScreenTest.kt | 33 +++++ 13 files changed, 348 insertions(+), 27 deletions(-) create mode 100644 app/src/main/java/com/pasich/encly/data/handoff/CacheHandoffStagingSweeper.kt create mode 100644 app/src/test/java/com/pasich/encly/presentation/viewmodel/ImportFromMyNotesViewModelTest.kt create mode 100644 app/src/test/java/com/pasich/encly/ui/screens/ImportFromMyNotesScreenTest.kt diff --git a/PRIVACY.md b/PRIVACY.md index 766e86f..0a1c74e 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -89,8 +89,8 @@ If you also use My Notes (the developer's earlier notes app), it can move its no and task categories into Encly. This happens **only on your device**, only when you start it in My Notes, and only after you unlock Encly and confirm what will be imported. Encly accepts the data only from the genuine My Notes app (checked by its signing certificate). The temporary copy -Encly makes while reading it is deleted right away; the imported notes are stored encrypted like -everything else. Nothing is sent over the network, and Encly sends nothing back to My Notes +Encly makes while reading it is deleted right away (or, if Encly is closed in the middle, the next +time it starts); the imported notes are stored encrypted like everything else. Nothing is sent over the network, and Encly sends nothing back to My Notes except how many items were imported. Attachments and images stay in My Notes. ## Deleting your data diff --git a/SECURITY.md b/SECURITY.md index 0f9c0b3..7164a6d 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -299,9 +299,11 @@ counts, and exposes no provider or anything readable. URI that is not `content://`) without reading the URI. - The vault must be unlocked through the normal lock screen first; an open session (within the auto-lock grace) is used as is. Backgrounding locks it as everywhere else. -- My Notes' ZIP (plaintext) is copied to Encly's private cache, because the URI grant ends with - the activity, then read and deleted in a `finally`; leftovers of a killed process are deleted - the next time. It is never logged. Reading is strict: one `handoff.json` entry, `format` and +- My Notes' ZIP (plaintext) is copied to a file of its own in Encly's private cache, because the + URI grant ends with the activity, then read and deleted in a `finally`. What a killed process + left there is deleted when the app next starts (off the main thread) and when a wipe-PIN erase + finishes; a file another hand-off of the same process is still reading is left alone. It is + never logged. Reading is strict: one `handoff.json` entry, `format` and `schema` checked first (a newer schema asks for an Encly update), at most 32 MiB compressed and 32 MiB uncompressed (counted on the bytes read, so a ZIP bomb stops there), 100 000 records per list, 1 Mi characters per text field. Editor.js HTML is reduced to plain text. diff --git a/app/src/main/java/com/pasich/encly/MyApplication.kt b/app/src/main/java/com/pasich/encly/MyApplication.kt index 194ed0e..eab4a26 100644 --- a/app/src/main/java/com/pasich/encly/MyApplication.kt +++ b/app/src/main/java/com/pasich/encly/MyApplication.kt @@ -3,8 +3,12 @@ package com.pasich.encly import android.app.Application import androidx.lifecycle.ProcessLifecycleOwner import com.pasich.encly.core.AppLogger +import com.pasich.encly.core.di.ApplicationScope import com.pasich.encly.core.security.SessionLockManager +import com.pasich.encly.data.handoff.HandoffStagingSweeper import dagger.hilt.android.HiltAndroidApp +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch import javax.inject.Inject @HiltAndroidApp @@ -14,6 +18,13 @@ class MyApplication : Application() { @Inject lateinit var sessionLockManager: SessionLockManager + @Inject + lateinit var handoffStaging: HandoffStagingSweeper + + @Inject + @ApplicationScope + lateinit var appScope: CoroutineScope + override fun onCreate() { super.onCreate() try { @@ -21,5 +32,7 @@ class MyApplication : Application() { } catch (e: Exception) { AppLogger.e("MyApplication", "Failed to initialize application", e) } + // A My Notes hand-off (plaintext) that a killed process left in the cache; off the main thread. + appScope.launch { handoffStaging.sweep() } } } diff --git a/app/src/main/java/com/pasich/encly/core/di/SecurityModule.kt b/app/src/main/java/com/pasich/encly/core/di/SecurityModule.kt index 248ae51..83727f4 100644 --- a/app/src/main/java/com/pasich/encly/core/di/SecurityModule.kt +++ b/app/src/main/java/com/pasich/encly/core/di/SecurityModule.kt @@ -13,6 +13,8 @@ import com.pasich.encly.core.security.SystemDeviceLockWatcher import com.pasich.encly.core.security.SystemLockoutClock import com.pasich.encly.core.security.VaultLockEvents import com.pasich.encly.core.security.VaultStore +import com.pasich.encly.data.handoff.CacheHandoffStagingSweeper +import com.pasich.encly.data.handoff.HandoffStagingSweeper import dagger.Binds import dagger.Module import dagger.Provides @@ -67,4 +69,7 @@ abstract class SecurityBindingsModule { @Binds abstract fun bindAutoLockPolicy(autoLock: AutoLock): AutoLockPolicy + + @Binds + abstract fun bindHandoffStagingSweeper(sweeper: CacheHandoffStagingSweeper): HandoffStagingSweeper } diff --git a/app/src/main/java/com/pasich/encly/core/security/SecurityManager.kt b/app/src/main/java/com/pasich/encly/core/security/SecurityManager.kt index c8aa3d4..6c4424c 100644 --- a/app/src/main/java/com/pasich/encly/core/security/SecurityManager.kt +++ b/app/src/main/java/com/pasich/encly/core/security/SecurityManager.kt @@ -5,6 +5,7 @@ import androidx.fragment.app.FragmentActivity import com.pasich.encly.core.AppLogger import com.pasich.encly.data.backup.BackupManager import com.pasich.encly.data.database.SecureDatabaseManager +import com.pasich.encly.data.handoff.HandoffStagingSweeper import java.security.SecureRandom import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicLong @@ -56,6 +57,8 @@ class SecurityManager @Inject constructor( private val secureDatabaseManager: SecureDatabaseManager, private val authenticationManager: AuthenticationManager, private val biometricManager: BiometricManager, + /** Hilt always passes the real one; the default only spares tests that never erase. */ + private val handoffStaging: HandoffStagingSweeper = HandoffStagingSweeper {}, ) { companion object { private const val TAG = "SecurityManager" @@ -386,6 +389,8 @@ class SecurityManager @Inject constructor( // Its slot went with the erase; this deletes the Keystore key behind it. biometricManager.disable() appFlags.edit().remove(BackupManager.LAST_EXPORT_KEY).commit() + // A My Notes hand-off a killed process left in the cache is plaintext of the old vault's time. + handoffStaging.sweep() if (stage == WipeStage.CLEANUP) authenticationManager.clearPendingWipe() } } diff --git a/app/src/main/java/com/pasich/encly/data/handoff/CacheHandoffStagingSweeper.kt b/app/src/main/java/com/pasich/encly/data/handoff/CacheHandoffStagingSweeper.kt new file mode 100644 index 0000000..13cf50a --- /dev/null +++ b/app/src/main/java/com/pasich/encly/data/handoff/CacheHandoffStagingSweeper.kt @@ -0,0 +1,14 @@ +package com.pasich.encly.data.handoff + +import android.content.Context +import dagger.hilt.android.qualifiers.ApplicationContext +import java.io.File +import javax.inject.Inject +import javax.inject.Singleton + +/** [HandoffStagingSweeper] for the staging directory in the app's cache. */ +@Singleton +class CacheHandoffStagingSweeper @Inject constructor(@param:ApplicationContext private val context: Context) : + HandoffStagingSweeper { + override fun sweep() = HandoffStaging.sweep(File(context.cacheDir, HandoffStaging.DIR_NAME)) +} diff --git a/app/src/main/java/com/pasich/encly/data/handoff/HandoffStaging.kt b/app/src/main/java/com/pasich/encly/data/handoff/HandoffStaging.kt index 1be3074..96aef42 100644 --- a/app/src/main/java/com/pasich/encly/data/handoff/HandoffStaging.kt +++ b/app/src/main/java/com/pasich/encly/data/handoff/HandoffStaging.kt @@ -5,10 +5,12 @@ import java.io.IOException import java.io.InputStream /** - * The one place a hand-off touches the disk: My Notes' ZIP is copied into [dir] (the app-private - * cache) because the URI grant ends with the activity and the reader needs - * random access, then parsed and mapped, then deleted in a `finally`. [clear] also removes - * what a killed process left behind. The plaintext lives on disk only for the parse. + * The one place a hand-off touches the disk: My Notes' ZIP is copied into a file of its own in + * [dir] (the app-private cache) because the URI grant ends with the activity and the reader needs + * random access, then parsed and mapped, then deleted in a `finally`. The plaintext lives on disk + * only for the parse. What a killed process left behind is deleted by [sweep], at the next start + * of the app and when a wipe-PIN erase finishes; a file a load in this process is still reading + * is never touched, so two hand-offs at once do not delete each other's. * * Deleting is all that can be done: on flash storage overwriting a file does not reliably * erase the old blocks, so it is not attempted. @@ -20,24 +22,52 @@ class HandoffStaging(private val dir: File, private val limits: HandoffLimits = * or [SecurityException] from [open] or the copy means the URI could not be read. */ fun load(open: () -> InputStream?): HandoffImport { - clear() + val zip = createStagingFile(dir) try { - if (!dir.mkdirs() && !dir.isDirectory) throw IOException("no staging directory") - val zip = File(dir, ZIP_NAME) val input = open() ?: throw HandoffException(HandoffError.INVALID_PAYLOAD) input.use { source -> zip.outputStream().use { MyNotesHandoffReader.copyLimited(source, it, limits) } } return MyNotesHandoffMapper.map(MyNotesHandoffReader.read(zip, limits)) } finally { - clear() + release(dir, zip) } } - fun clear() { - dir.deleteRecursively() - } - companion object { const val DIR_NAME = "mynotes-handoff" - private const val ZIP_NAME = "handoff.zip" + private const val ZIP_PREFIX = "handoff-" + private const val ZIP_SUFFIX = ".zip" + + /** Paths of the files loads in this process are using; also the lock of the directory. */ + private val inUse = mutableSetOf() + + /** + * Deletes everything in [dir] that no load in this process is using (what killed + * processes left behind), and [dir] itself once it is empty. + */ + fun sweep(dir: File) { + synchronized(inUse) { + dir.listFiles()?.filterNot { it.path in inUse }?.forEach { it.deleteRecursively() } + if (inUse.isEmpty()) dir.delete() + } + } + + private fun createStagingFile(dir: File): File = synchronized(inUse) { + if (!dir.mkdirs() && !dir.isDirectory) throw IOException("no staging directory") + File.createTempFile(ZIP_PREFIX, ZIP_SUFFIX, dir).also { inUse += it.path } + } + + private fun release(dir: File, zip: File) { + synchronized(inUse) { + zip.delete() + inUse -= zip.path + // Only when no other load is using it (delete fails on a directory that is not empty). + if (inUse.isEmpty()) dir.delete() + } + } } } + +/** Deletes what hand-offs of killed processes left in the app's cache (see [HandoffStaging.sweep]). */ +fun interface HandoffStagingSweeper { + fun sweep() +} diff --git a/app/src/main/java/com/pasich/encly/presentation/screen/handoff/ImportFromMyNotesScreen.kt b/app/src/main/java/com/pasich/encly/presentation/screen/handoff/ImportFromMyNotesScreen.kt index 1af5a82..ab381f8 100644 --- a/app/src/main/java/com/pasich/encly/presentation/screen/handoff/ImportFromMyNotesScreen.kt +++ b/app/src/main/java/com/pasich/encly/presentation/screen/handoff/ImportFromMyNotesScreen.kt @@ -48,7 +48,9 @@ fun ImportFromMyNotesScreen( ) { val locked by viewModel.locked.collectAsState() val step by viewModel.step.collectAsState() - if (locked) { + // A failure shows even while the vault is closed: "nothing can open the vault" comes from + // the lock screen itself, and nothing of the vault is on that page. + if (locked && step !is HandoffStep.Failed) { // Unlocking publishes itself to the session (LockViewModel); the steps then follow. val exits = remember(viewModel, onClose) { LockExits( diff --git a/app/src/main/java/com/pasich/encly/presentation/viewmodel/ImportFromMyNotesViewModel.kt b/app/src/main/java/com/pasich/encly/presentation/viewmodel/ImportFromMyNotesViewModel.kt index 8decbdc..0a18e90 100644 --- a/app/src/main/java/com/pasich/encly/presentation/viewmodel/ImportFromMyNotesViewModel.kt +++ b/app/src/main/java/com/pasich/encly/presentation/viewmodel/ImportFromMyNotesViewModel.kt @@ -2,6 +2,7 @@ package com.pasich.encly.presentation.viewmodel import android.content.Context import android.net.Uri +import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.pasich.encly.core.backup.BackupException @@ -63,6 +64,7 @@ class ImportFromMyNotesViewModel @Inject constructor( private val sessionLockManager: SessionLockManager, private val backupManager: BackupManager, @param:IoDispatcher private val ioDispatcher: CoroutineDispatcher, + private val savedStateHandle: SavedStateHandle, ) : ViewModel() { private val staging = HandoffStaging(File(context.cacheDir, HandoffStaging.DIR_NAME)) @@ -76,10 +78,18 @@ class ImportFromMyNotesViewModel @Inject constructor( private var started = false private var handoff: HandoffImport? = null - /** Starts the hand-off of [uri] once; a recreated activity calls it again and is ignored. */ + /** + * Starts the hand-off of [uri] once; a recreated activity calls it again and is ignored. One + * that already finished before the process was killed shows its counts again instead of + * offering the same import a second time. + */ fun start(uri: Uri) { if (started) return started = true + restoredDone()?.let { + _step.value = it + return + } viewModelScope.launch { if (!vaultOpen() && !requireUnlock()) { _step.value = HandoffStep.Failed(HandoffError.FAILED, vaultUnavailable = true) @@ -102,7 +112,7 @@ class ImportFromMyNotesViewModel @Inject constructor( backupManager.import(pending.payload, ImportMode.MERGE, TagMatch.UID_OR_NAME) } handoff = null - HandoffStep.Done(summary, skipped = summary.skipped + pending.dropped) + HandoffStep.Done(summary, skipped = summary.skipped + pending.dropped).also(::rememberDone) } catch (_: BackupException) { HandoffStep.Failed(HandoffError.FAILED) } @@ -116,8 +126,35 @@ class ImportFromMyNotesViewModel @Inject constructor( } override fun onCleared() { + // The staged copy is already gone: each load deletes its own. handoff = null - staging.clear() + } + + /** Only the counts, which is all Done shows: nothing of the notes themselves. */ + private fun rememberDone(done: HandoffStep.Done) { + val summary = done.summary + savedStateHandle[KEY_DONE] = intArrayOf( + summary.notesAdded, + summary.tagsAdded, + summary.tasksAdded, + summary.skipped, + summary.subtasksAdded, + done.skipped, + ) + } + + private fun restoredDone(): HandoffStep.Done? { + val counts = savedStateHandle.get(KEY_DONE)?.takeIf { it.size == DONE_COUNTS } ?: return null + // In the order rememberDone wrote them. + val next = counts.iterator() + val summary = ImportSummary( + notesAdded = next.nextInt(), + tagsAdded = next.nextInt(), + tasksAdded = next.nextInt(), + skipped = next.nextInt(), + subtasksAdded = next.nextInt(), + ) + return HandoffStep.Done(summary, skipped = next.nextInt()) } private fun vaultOpen(): Boolean = securityManager.isDatabaseUnlocked() && !sessionLockManager.locked.value @@ -149,3 +186,7 @@ class ImportFromMyNotesViewModel @Inject constructor( HandoffStep.Failed(HandoffError.FAILED) } } + +/** SavedStateHandle key of a finished hand-off's counts (see ImportFromMyNotesViewModel.start). */ +private const val KEY_DONE = "handoff_done_counts" +private const val DONE_COUNTS = 6 diff --git a/app/src/test/java/com/pasich/encly/core/security/WipePinTest.kt b/app/src/test/java/com/pasich/encly/core/security/WipePinTest.kt index f151f81..c29c112 100644 --- a/app/src/test/java/com/pasich/encly/core/security/WipePinTest.kt +++ b/app/src/test/java/com/pasich/encly/core/security/WipePinTest.kt @@ -582,6 +582,17 @@ class WipePinTest { assertNull(vault.auth.pendingWipe()) } + @Test + fun theEraseAlsoDeletesWhatAHandoffLeftInTheCache() { + val vault = Vault() + `when`(vault.database.unlockDatabase(anyByteArray(), anyBoolean())).thenReturn(true) + assertEquals(VaultUnlockResult.SUCCESS, vault.security.unlockWithPin(pin(WIPE_PIN))) + + vault.security.completePendingWipe() + + assertEquals(1, vault.sweeps) + } + @Test fun theStartupFinishRunsOncePerProcess() { val vault = Vault() @@ -665,7 +676,10 @@ class WipePinTest { val seed = SeedPhraseManager(store) val database: SecureDatabaseManager = mock(SecureDatabaseManager::class.java) val biometric: BiometricManager = mock(BiometricManager::class.java) - val security = SecurityManager(prefs, store, seed, database, auth, biometric) + + /** How often the My Notes hand-off's staging directory was swept. */ + var sweeps = 0 + val security = SecurityManager(prefs, store, seed, database, auth, biometric) { sweeps++ } init { assertTrue(seed.initializeVault(seed.generateMnemonic().chars)) @@ -679,7 +693,7 @@ class WipePinTest { } /** The same files read by a new process. */ - fun restart() = SecurityManager(prefs, store, seed, database, auth, biometric) + fun restart() = SecurityManager(prefs, store, seed, database, auth, biometric) { sweeps++ } } private fun thread(block: () -> Unit): Thread = Thread(block).apply { start() } diff --git a/app/src/test/java/com/pasich/encly/data/handoff/HandoffStagingTest.kt b/app/src/test/java/com/pasich/encly/data/handoff/HandoffStagingTest.kt index 8445cf1..3cb9a6d 100644 --- a/app/src/test/java/com/pasich/encly/data/handoff/HandoffStagingTest.kt +++ b/app/src/test/java/com/pasich/encly/data/handoff/HandoffStagingTest.kt @@ -5,6 +5,7 @@ import com.pasich.encly.data.handoff.HandoffFixtures.handoffZip import com.pasich.encly.data.handoff.HandoffFixtures.note import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test import org.junit.rules.TemporaryFolder @@ -54,10 +55,50 @@ class HandoffStagingTest { } @Test - fun leftoversOfAKilledRunAreCleared() { + fun leftoversOfAKilledRunAreSwept() { stagingDir.mkdirs() - File(stagingDir, "handoff.zip").writeText("plaintext from a run that died") - HandoffStaging(stagingDir).clear() + File(stagingDir, "handoff-123.zip").writeText("plaintext from a run that died") + HandoffStaging.sweep(stagingDir) + assertFalse(stagingDir.exists()) + } + + @Test + fun aSweepDuringALoadLeavesThatLoadsFileAlone() { + val zip = handoffZip(temp.newFolder("source"), handoff(notes = listOf(note("n1")))) + File(stagingDir.apply { mkdirs() }, "handoff-old.zip").writeText("left by a killed run") + var during: List = emptyList() + + val loaded = HandoffStaging(stagingDir).load { + // Another hand-off, or the app's start, sweeps while this one is copying. + HandoffStaging.sweep(stagingDir) + during = stagingDir.list().orEmpty().toList() + zip.inputStream() + } + + assertEquals(1, loaded.preview.notes) + assertEquals("only this load's own file is left", 1, during.size) + assertTrue(during.single() != "handoff-old.zip") + assertFalse(stagingDir.exists()) + } + + @Test + fun twoLoadsAtOnceUseFilesOfTheirOwn() { + val zip = handoffZip(temp.newFolder("source"), handoff(notes = listOf(note("n1")))) + var inner: HandoffImport? = null + var filesDuringInner = 0 + + val outer = HandoffStaging(stagingDir).load { + inner = HandoffStaging(stagingDir).load { + filesDuringInner = stagingDir.list().orEmpty().size + zip.inputStream() + } + // The inner load deleted its own file only, so this one still copies into its file. + zip.inputStream() + } + + assertEquals(2, filesDuringInner) + assertEquals(1, outer.preview.notes) + assertEquals(1, inner?.preview?.notes) assertFalse(stagingDir.exists()) } } diff --git a/app/src/test/java/com/pasich/encly/presentation/viewmodel/ImportFromMyNotesViewModelTest.kt b/app/src/test/java/com/pasich/encly/presentation/viewmodel/ImportFromMyNotesViewModelTest.kt new file mode 100644 index 0000000..3da0dc0 --- /dev/null +++ b/app/src/test/java/com/pasich/encly/presentation/viewmodel/ImportFromMyNotesViewModelTest.kt @@ -0,0 +1,121 @@ +package com.pasich.encly.presentation.viewmodel + +import android.app.Application +import android.content.Context +import android.net.Uri +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.SavedStateHandle +import androidx.test.core.app.ApplicationProvider +import com.pasich.encly.core.security.SecurityManager +import com.pasich.encly.core.security.SessionLockManager +import com.pasich.encly.data.backup.BackupManager +import com.pasich.encly.data.handoff.HandoffFixtures.handoff +import com.pasich.encly.data.handoff.HandoffFixtures.handoffZip +import com.pasich.encly.data.handoff.HandoffFixtures.note +import com.pasich.encly.testutil.InMemorySharedPreferences +import com.pasich.encly.testutil.InMemoryVaultDataStore +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import org.mockito.Mockito.mock +import org.mockito.Mockito.never +import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.io.File + +/** The hand-off's steps across a killed process: a finished import is not offered again. */ +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35], application = Application::class) +class ImportFromMyNotesViewModelTest { + @get:Rule + val temp = TemporaryFolder() + + private val context: Context get() = ApplicationProvider.getApplicationContext() + private val dispatcher = UnconfinedTestDispatcher() + private lateinit var security: SecurityManager + private lateinit var sessionLock: SessionLockManager + private lateinit var store: InMemoryVaultDataStore + private lateinit var savedState: SavedStateHandle + + @Before + fun setUp() { + Dispatchers.setMain(dispatcher) + security = mock(SecurityManager::class.java) + // The vault is open (within the auto-lock grace). + `when`(security.isDatabaseUnlocked()).thenReturn(true) + sessionLock = SessionLockManager(security) + sessionLock.onStart(mock(LifecycleOwner::class.java)) + store = InMemoryVaultDataStore() + savedState = SavedStateHandle() + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + private fun viewModel() = ImportFromMyNotesViewModel( + context = context, + securityManager = security, + sessionLockManager = sessionLock, + backupManager = BackupManager(security, store, InMemorySharedPreferences()), + ioDispatcher = dispatcher, + savedStateHandle = savedState, + ) + + @Test + fun aFinishedHandoffShowsItsCountsAgainAfterTheProcessWasKilled() { + val zip = handoffZip(temp.newFolder("source"), handoff(notes = listOf(note("n1"), note("n2")))) + val uri = Uri.fromFile(zip) + val first = viewModel() + first.start(uri) + assertTrue(first.step.value is HandoffStep.Preview) + first.confirm() + val done = first.step.value as HandoffStep.Done + assertEquals(2, done.summary.notesAdded) + + // A new process: the activity comes back with the same intent and the saved state. + zip.delete() + val restored = viewModel() + restored.start(uri) + + assertEquals(done, restored.step.value) + // Not read or imported again: no second preview, no "0 notes added". + assertEquals(2, runBlocking { store.snapshot() }.notes.size) + } + + @Test + fun aHandoffNotFinishedBeforeTheKillStartsOver() { + val zip = handoffZip(temp.newFolder("source"), handoff(notes = listOf(note("n1")))) + viewModel().start(Uri.fromFile(zip)) + + val restored = viewModel() + restored.start(Uri.fromFile(zip)) + + assertTrue(restored.step.value is HandoffStep.Preview) + verify(security, never()).resolveInitialStatus() + } + + @Test + fun theStagingDirectoryIsEmptyAfterTheRead() { + val zip = handoffZip(temp.newFolder("source"), handoff(notes = listOf(note("n1")))) + + viewModel().start(Uri.fromFile(zip)) + + assertTrue(File(context.cacheDir, "mynotes-handoff").list().isNullOrEmpty()) + } +} diff --git a/app/src/test/java/com/pasich/encly/ui/screens/ImportFromMyNotesScreenTest.kt b/app/src/test/java/com/pasich/encly/ui/screens/ImportFromMyNotesScreenTest.kt new file mode 100644 index 0000000..c3f91fe --- /dev/null +++ b/app/src/test/java/com/pasich/encly/ui/screens/ImportFromMyNotesScreenTest.kt @@ -0,0 +1,33 @@ +package com.pasich.encly.ui.screens + +import androidx.lifecycle.SavedStateHandle +import com.pasich.encly.R +import com.pasich.encly.presentation.screen.handoff.ImportFromMyNotesScreen +import com.pasich.encly.presentation.viewmodel.ImportFromMyNotesViewModel +import kotlinx.coroutines.Dispatchers +import org.junit.Test + +/** The hand-off screen while the vault is closed. */ +class ImportFromMyNotesScreenTest : ComposeScreenTest() { + private val app by lazy { TestApp(context) } + + @Test + fun aVaultNothingCanOpenIsReportedInsteadOfTheLockScreen() { + val handoff = ImportFromMyNotesViewModel( + context = context, + securityManager = app.security, + sessionLockManager = app.sessionLock, + backupManager = app.backupManager, + ioDispatcher = Dispatchers.Main, + savedStateHandle = SavedStateHandle(), + ) + app.sessionLock.requireUnlock() + setScreen(viewModels(handoff, app.lock())) { ImportFromMyNotesScreen(onClose = {}) } + waitForText(str(R.string.lock_title)) + + // The lock screen found that the PIN key is gone and there is nothing else to unlock with. + rule.runOnIdle { handoff.onVaultLost() } + + waitForText(str(R.string.handoff_error_vault)) + } +} From c6085ec1487e92b2632153061b2bdd6240f470d7 Mon Sep 17 00:00:00 2001 From: pasichdev Date: Mon, 5 Oct 2026 13:49:43 +0300 Subject: [PATCH 17/19] fix(security): keep the vault open when the activity is recreated Rotating the phone, switching dark mode or changing the font size recreates the activity, which resolved the start status again and found a committed vault: it re-locked it and showed the PIN pad, dropping an open task sheet or inline field. The comment there already meant only a vault that is not open; the check was missing. While this process holds the vault open (the session key and the database), the start status is now the open vault. Refs: #55 --- .../encly/core/security/SecurityManager.kt | 6 ++++++ .../encly/core/security/SecurityManagerTest.kt | 16 ++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/app/src/main/java/com/pasich/encly/core/security/SecurityManager.kt b/app/src/main/java/com/pasich/encly/core/security/SecurityManager.kt index 6c4424c..cca8e2a 100644 --- a/app/src/main/java/com/pasich/encly/core/security/SecurityManager.kt +++ b/app/src/main/java/com/pasich/encly/core/security/SecurityManager.kt @@ -97,6 +97,9 @@ class SecurityManager @Inject constructor( */ @Suppress("TooGenericExceptionCaught") // Any startup failure must route, never crash-loop. fun resolveInitialStatus(): InitialStatus { + // A recreated activity (rotation, dark mode, font size) asks again while the vault is + // open in this process: it stays open. Only a vault that is not open starts locked. + if (isSessionOpen()) return InitialStatus.MAIN.also { securityStatus = it } val status = try { initializeSecurity() } catch (e: Exception) { @@ -465,6 +468,9 @@ class SecurityManager @Inject constructor( fun isDatabaseUnlocked(): Boolean = secureDatabaseManager.isDatabaseUnlocked() + /** True while this process holds the vault open: the session key and the database. */ + private fun isSessionOpen(): Boolean = sessionDek != null && secureDatabaseManager.isDatabaseUnlocked() + fun isOnboardingShow(): Boolean = !isOnboardingShown() private fun isOnboardingShown(): Boolean = appFlags.getBoolean(ONBOARDING_SHOWN_KEY, false) diff --git a/app/src/test/java/com/pasich/encly/core/security/SecurityManagerTest.kt b/app/src/test/java/com/pasich/encly/core/security/SecurityManagerTest.kt index 9346b5f..c1029a1 100644 --- a/app/src/test/java/com/pasich/encly/core/security/SecurityManagerTest.kt +++ b/app/src/test/java/com/pasich/encly/core/security/SecurityManagerTest.kt @@ -74,6 +74,22 @@ class SecurityManagerTest { assertEquals(InitialStatus.AUTH, manager.resolveInitialStatus()) } + @Test + fun aRecreatedActivityKeepsTheOpenVaultOpenAndALockedOneAsksAgain() { + commitVault() + `when`(auth.hasPinSlot()).thenReturn(true) + `when`(database.unlockDatabase(anyByteArray(), anyBoolean())).thenReturn(true) + assertTrue(manager.unlockWithRawKey(ByteArray(32) { 7 })) + `when`(database.isDatabaseUnlocked()).thenReturn(true) + + // Rotation, dark mode or font size: the activity asks again while the vault is open. + assertEquals(InitialStatus.MAIN, manager.resolveInitialStatus()) + + manager.lock() + `when`(database.isDatabaseUnlocked()).thenReturn(false) + assertEquals(InitialStatus.AUTH, manager.resolveInitialStatus()) + } + @Test fun committedVaultWithOnlyRecoverySlotStillAsksForAuthentication() { commitVault() From b710b61a34026b0f759d4933bc514b40285212c1 Mon Sep 17 00:00:00 2001 From: pasichdev Date: Mon, 5 Oct 2026 13:49:43 +0300 Subject: [PATCH 18/19] fix(tasks): don't save a half-typed sub-task when the phone rotates A rotation hides the keyboard just before it disposes the inline field, and a hidden keyboard closes the field and saves its text. Typing "Tea" and turning the phone stored "Tea" as a sub-task and opened an empty field. The keyboard now has to stay hidden for a moment before it closes the field; a field disposed by the rotation cancels that and comes back open with its text. Back and the keyboard's hide key still close it. Refs: #55 --- .../encly/presentation/components/tasks/SubtaskTree.kt | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/pasich/encly/presentation/components/tasks/SubtaskTree.kt b/app/src/main/java/com/pasich/encly/presentation/components/tasks/SubtaskTree.kt index 41160cf..f5dd5a2 100644 --- a/app/src/main/java/com/pasich/encly/presentation/components/tasks/SubtaskTree.kt +++ b/app/src/main/java/com/pasich/encly/presentation/components/tasks/SubtaskTree.kt @@ -338,7 +338,7 @@ private fun InlineSubtaskField( LaunchedEffect(Unit) { focusRequester.requestFocus() } BackHandler(onBack = close) - CloseWhenKeyboardHides(focused = focused, onClose = close) + CloseWhenKeyboardHides(focused = focused, onClose = { if (activity?.isChangingConfigurations != true) close() }) // The list is ime-padded: once the keyboard is up (or its height changes), scroll to the field. val keyboardHeight = WindowInsets.ime.getBottom(LocalDensity.current) LaunchedEffect(focused, keyboardHeight) { @@ -387,8 +387,15 @@ private fun CloseWhenKeyboardHides(focused: Boolean, onClose: () -> Unit) { seenOpen -> { seenOpen = false + // A rotation hides the keyboard too, just before it disposes the field. Wait a + // moment: a disposed field cancels this, and the field comes back open with its + // text instead of saving a half-typed title. + delay(KEYBOARD_HIDE_SETTLE_MS) currentOnClose() } } } } + +/** How long a hidden keyboard must stay hidden before it closes the inline field. */ +private const val KEYBOARD_HIDE_SETTLE_MS = 250L From 1ddde726e56987ae7c06cdba972a5d28d7badf36 Mon Sep 17 00:00:00 2001 From: pasichdev Date: Mon, 5 Oct 2026 13:58:23 +0300 Subject: [PATCH 19/19] release: 2.1.0, sub-tasks, an optional wipe PIN and the move from My Notes Bumps the version to 2.1.0 (versionCode 20100), moves the Unreleased notes under 2.1.0 and adds the 2.1.0 release notes for the stores in all nine store languages. The release changes the database (version 4) and the backup format (schema 3), which older versions cannot open; the CHANGELOG says so under Compatibility. --- CHANGELOG.md | 11 ++++++++++- README.md | 2 +- fastlane/metadata/android/de-DE/changelogs/20100.txt | 5 +++++ fastlane/metadata/android/en-US/changelogs/20100.txt | 5 +++++ fastlane/metadata/android/es-ES/changelogs/20100.txt | 5 +++++ fastlane/metadata/android/fr-FR/changelogs/20100.txt | 5 +++++ fastlane/metadata/android/it-IT/changelogs/20100.txt | 5 +++++ fastlane/metadata/android/nl-NL/changelogs/20100.txt | 5 +++++ fastlane/metadata/android/pl-PL/changelogs/20100.txt | 5 +++++ fastlane/metadata/android/pt-PT/changelogs/20100.txt | 5 +++++ fastlane/metadata/android/uk/changelogs/20100.txt | 5 +++++ version.properties | 4 ++-- 12 files changed, 58 insertions(+), 4 deletions(-) create mode 100644 fastlane/metadata/android/de-DE/changelogs/20100.txt create mode 100644 fastlane/metadata/android/en-US/changelogs/20100.txt create mode 100644 fastlane/metadata/android/es-ES/changelogs/20100.txt create mode 100644 fastlane/metadata/android/fr-FR/changelogs/20100.txt create mode 100644 fastlane/metadata/android/it-IT/changelogs/20100.txt create mode 100644 fastlane/metadata/android/nl-NL/changelogs/20100.txt create mode 100644 fastlane/metadata/android/pl-PL/changelogs/20100.txt create mode 100644 fastlane/metadata/android/pt-PT/changelogs/20100.txt create mode 100644 fastlane/metadata/android/uk/changelogs/20100.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index e439c3f..49dff85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,14 @@ IzzyOnDroid) and used as the GitHub Release notes. ## [Unreleased] +## [2.1.0] - 2026-10-05 + +versionCode 20100. + +Tasks get sub-tasks, an optional wipe PIN can silently erase the vault, and notes can move over +from My Notes in one step. The database and the backup format change with this version, so an +older Encly cannot open either (see Compatibility). + ### Added - Sub-tasks: a task can have a checklist of sub-tasks (one level). On the Tasks list an open @@ -186,6 +194,7 @@ The last release of the old storage format (versionCode 30): the SQLCipher key w Keystore-sealed seed hash, with an optional 4-digit PIN. It had no public users; superseded by 2.0.0. -[Unreleased]: https://github.com/pasichDev/Encly/compare/v2.0.1...HEAD +[Unreleased]: https://github.com/pasichDev/Encly/compare/v2.1.0...HEAD +[2.1.0]: https://github.com/pasichDev/Encly/releases/tag/v2.1.0 [2.0.1]: https://github.com/pasichDev/Encly/releases/tag/v2.0.1 [2.0.0]: https://github.com/pasichDev/Encly/releases/tag/v2.0.0 diff --git a/README.md b/README.md index 7b5f991..9b5bffe 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,7 @@ Every release ships a `SHA256SUMS` file (and `SHA256SUMS.asc` when it is GPG-sig ```bash sha256sum -c --ignore-missing SHA256SUMS # APK matches the published checksum -apksigner verify --print-certs Encly-2.0.1-fdroid.apk +apksigner verify --print-certs Encly-2.1.0-fdroid.apk ``` `apksigner` (Android SDK build-tools) must print this release signing certificate SHA-256 diff --git a/fastlane/metadata/android/de-DE/changelogs/20100.txt b/fastlane/metadata/android/de-DE/changelogs/20100.txt new file mode 100644 index 0000000..9312742 --- /dev/null +++ b/fastlane/metadata/android/de-DE/changelogs/20100.txt @@ -0,0 +1,5 @@ +Encly 2.1.0 +• Unteraufgaben: Teile eine Aufgabe in Schritte. Die Liste zeigt den nächsten Schritt und den Fortschritt; tippe auf eine Aufgabe, um alle zu sehen und zu bearbeiten. +• Lösch-PIN (optional): eine zweite PIN, die den Tresor unbemerkt löscht und einen leeren öffnet, wenn dich jemand zum Entsperren zwingt. +• Notizen aus My Notes in einem Schritt übernehmen. +Diese Version ändert die Datenbank und das Backup-Format: ältere Versionen können sie nicht öffnen. diff --git a/fastlane/metadata/android/en-US/changelogs/20100.txt b/fastlane/metadata/android/en-US/changelogs/20100.txt new file mode 100644 index 0000000..50b106e --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/20100.txt @@ -0,0 +1,5 @@ +Encly 2.1.0 +• Sub-tasks: break a task into steps. The list shows each task's next step and its progress; tap a task to see and edit all of them. +• Wipe PIN (optional): a second PIN that silently erases the vault and opens an empty one, for when someone forces you to unlock. +• Move your notes from My Notes in one step. +This version updates the database and the backup format: older versions can't open them. diff --git a/fastlane/metadata/android/es-ES/changelogs/20100.txt b/fastlane/metadata/android/es-ES/changelogs/20100.txt new file mode 100644 index 0000000..6ea467b --- /dev/null +++ b/fastlane/metadata/android/es-ES/changelogs/20100.txt @@ -0,0 +1,5 @@ +Encly 2.1.0 +• Subtareas: divide una tarea en pasos. La lista muestra el siguiente paso y el progreso; toca una tarea para ver y editar todos. +• PIN de borrado (opcional): un segundo PIN que borra la bóveda sin dejar rastro y abre una vacía si alguien te obliga a desbloquear. +• Trae tus notas de My Notes en un solo paso. +Esta versión cambia la base de datos y el formato de las copias: las versiones anteriores no podrán abrirlas. diff --git a/fastlane/metadata/android/fr-FR/changelogs/20100.txt b/fastlane/metadata/android/fr-FR/changelogs/20100.txt new file mode 100644 index 0000000..30a8f9b --- /dev/null +++ b/fastlane/metadata/android/fr-FR/changelogs/20100.txt @@ -0,0 +1,5 @@ +Encly 2.1.0 +• Sous-tâches : découpez une tâche en étapes. La liste montre l'étape suivante et la progression ; touchez une tâche pour toutes les voir et les modifier. +• PIN d'effacement (facultatif) : un second PIN qui efface le coffre sans laisser de trace et en ouvre un vide si l'on vous force à déverrouiller. +• Importez vos notes de My Notes en une étape. +Cette version change la base de données et le format des sauvegardes : les versions antérieures ne pourront pas les ouvrir. diff --git a/fastlane/metadata/android/it-IT/changelogs/20100.txt b/fastlane/metadata/android/it-IT/changelogs/20100.txt new file mode 100644 index 0000000..670ce31 --- /dev/null +++ b/fastlane/metadata/android/it-IT/changelogs/20100.txt @@ -0,0 +1,5 @@ +Encly 2.1.0 +• Sotto-attività: dividi un'attività in passi. L'elenco mostra il passo successivo e l'avanzamento; tocca un'attività per vederli e modificarli tutti. +• PIN di cancellazione (facoltativo): un secondo PIN che cancella la cassaforte senza lasciare segni e ne apre una vuota se qualcuno ti costringe a sbloccare. +• Sposta le note da My Notes in un solo passo. +Questa versione cambia il database e il formato dei backup: le versioni precedenti non potranno aprirli. diff --git a/fastlane/metadata/android/nl-NL/changelogs/20100.txt b/fastlane/metadata/android/nl-NL/changelogs/20100.txt new file mode 100644 index 0000000..97fae66 --- /dev/null +++ b/fastlane/metadata/android/nl-NL/changelogs/20100.txt @@ -0,0 +1,5 @@ +Encly 2.1.0 +• Subtaken: deel een taak op in stappen. De lijst toont de volgende stap en de voortgang; tik op een taak om ze allemaal te zien en te bewerken. +• Wis-pincode (optioneel): een tweede pincode die de kluis ongemerkt wist en een lege opent als iemand je dwingt te ontgrendelen. +• Haal je notities uit My Notes in één stap over. +Deze versie wijzigt de database en het back-upformaat: oudere versies kunnen ze niet openen. diff --git a/fastlane/metadata/android/pl-PL/changelogs/20100.txt b/fastlane/metadata/android/pl-PL/changelogs/20100.txt new file mode 100644 index 0000000..62e609f --- /dev/null +++ b/fastlane/metadata/android/pl-PL/changelogs/20100.txt @@ -0,0 +1,5 @@ +Encly 2.1.0 +• Podzadania: podziel zadanie na kroki. Lista pokazuje następny krok i postęp; dotknij zadania, aby zobaczyć i edytować wszystkie. +• PIN kasujący (opcjonalny): drugi PIN, który bez śladu kasuje sejf i otwiera pusty, gdy ktoś zmusza cię do odblokowania. +• Przenieś notatki z My Notes w jednym kroku. +Ta wersja zmienia bazę danych i format kopii zapasowych: starsze wersje ich nie otworzą. diff --git a/fastlane/metadata/android/pt-PT/changelogs/20100.txt b/fastlane/metadata/android/pt-PT/changelogs/20100.txt new file mode 100644 index 0000000..36fc9fa --- /dev/null +++ b/fastlane/metadata/android/pt-PT/changelogs/20100.txt @@ -0,0 +1,5 @@ +Encly 2.1.0 +• Subtarefas: divida uma tarefa em passos. A lista mostra o passo seguinte e o progresso; toque numa tarefa para ver e editar todos. +• PIN de apagamento (opcional): um segundo PIN que apaga o cofre sem deixar sinal e abre um vazio se alguém o obrigar a desbloquear. +• Traga as suas notas do My Notes num só passo. +Esta versão altera a base de dados e o formato das cópias: versões anteriores não as conseguem abrir. diff --git a/fastlane/metadata/android/uk/changelogs/20100.txt b/fastlane/metadata/android/uk/changelogs/20100.txt new file mode 100644 index 0000000..d306f70 --- /dev/null +++ b/fastlane/metadata/android/uk/changelogs/20100.txt @@ -0,0 +1,5 @@ +Encly 2.1.0 +• Підзавдання: розбийте завдання на кроки. Список показує наступний крок і прогрес; торкніться завдання, щоб побачити й змінити всі. +• PIN для стирання (за бажанням): другий PIN, що непомітно стирає сховище й відкриває порожнє, якщо вас змушують розблокувати. +• Перенесення нотаток з «Мої Нотатки» за один крок. +Ця версія оновлює базу даних і формат резервних копій: старіші версії їх не відкриють. diff --git a/version.properties b/version.properties index 3ac77f1..611d708 100644 --- a/version.properties +++ b/version.properties @@ -7,5 +7,5 @@ # Release tags must be "v" + versionName (e.g. v2.0.0); .github/workflows/release.yml # refuses a tag that does not match this file. See CONTRIBUTING.md > Releasing. VERSION_MAJOR=2 -VERSION_MINOR=0 -VERSION_PATCH=1 +VERSION_MINOR=1 +VERSION_PATCH=0