diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1ad87d0..49dff85 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,6 +11,57 @@ 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
+ 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.
+- 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.
+- 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
+
+- 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.
+
+### 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
versionCode 20001.
@@ -143,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/PRIVACY.md b/PRIVACY.md
index 1abec5c..0a1c74e 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 (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
Uninstalling Encly, or clearing its storage in Android settings, permanently deletes all of
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/SECURITY.md b/SECURITY.md
index 350be71..7164a6d 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -46,7 +46,96 @@ 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), 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
+ 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, 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 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.
+- **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.
+- **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 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
+ (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. 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
@@ -159,8 +248,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.
@@ -184,6 +274,43 @@ 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 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 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.
+- 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.
@@ -213,8 +340,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.
@@ -257,8 +384,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
@@ -317,6 +444,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/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/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
+
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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ packageManager.resolveContentProvider(authority, 0)?.packageName
+ }
+ val uri = intent?.data
+ if (refused != null || uri == null) {
+ finishWith(refused ?: HandoffError.INVALID_PAYLOAD)
+ return
+ }
+ // Leaving before the end (Back, the close button) reports a cancellation.
+ setResult(RESULT_CANCELED, reasonIntent(HandoffError.CANCELLED))
+ enableEdgeToEdge()
+
+ var themeReady by mutableStateOf(false)
+ lifecycleScope.launch {
+ // The stored palette and fonts, read off the main thread as MainActivity does.
+ withContext(Dispatchers.IO) {
+ try {
+ settingsRepository.loadThemeSettings(existingInstall = !securityManager.isOnboardingShow())
+ } catch (_: IOException) {
+ // An unreadable settings file: the theme starts from its defaults.
+ }
+ }
+ themeReady = true
+ }
+ lifecycleScope.launch { viewModel.step.collect(::publishResult) }
+ viewModel.start(uri)
+
+ setContent {
+ if (!themeReady) return@setContent
+ val strictKeyboard by keyboardPrivacy.strict.collectAsState()
+ AppTheme {
+ SecureTextInputBoundary(strict = strictKeyboard) {
+ SensitiveClipboardBoundary {
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(MaterialTheme.colorScheme.background)
+ .windowInsetsPadding(WindowInsets.statusBars),
+ ) {
+ ImportFromMyNotesScreen(onClose = ::finish)
+ }
+ }
+ }
+ }
+ }
+ }
+
+ /** 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) {
+ is HandoffStep.Done -> 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 = HandoffRequest.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..e461e0d 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
@@ -30,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
@@ -45,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
@@ -125,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
@@ -136,6 +143,7 @@ class MainActivity : AppCompatActivity() {
App(
navController = navController,
startDestination = destination.name,
+ eraseEpoch = securityManager::eraseEpoch,
shielded = shielded,
locked = locked,
strictKeyboard = strictKeyboard,
@@ -147,20 +155,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)
}
}
@@ -200,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:"
@@ -223,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.
@@ -241,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,
@@ -284,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/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/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/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/AuthenticationManager.kt b/app/src/main/java/com/pasich/encly/core/security/AuthenticationManager.kt
index d5d12a2..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
@@ -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,83 @@ 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 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 {
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 {
+ // 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 {
- 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 +197,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 +217,110 @@ 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() = 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) }
+
fun hasPinSlot(): Boolean = store.contains(PIN_SLOT_KEY)
fun getAuthType(): AuthType {
@@ -218,14 +376,173 @@ 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]; 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 {
+ 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 +566,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 +642,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 +708,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 +754,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..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
@@ -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,24 @@ 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. 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()
+ fun ensureKey(slot: PinKeySlot): Boolean
- /** 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 +74,22 @@ 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): Boolean {
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)
+ return !exists
}
- 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 +97,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 +120,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 +135,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 +145,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 +156,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 +176,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..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
@@ -3,7 +3,12 @@ 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 com.pasich.encly.data.handoff.HandoffStagingSweeper
+import java.security.SecureRandom
+import java.util.concurrent.atomic.AtomicBoolean
+import java.util.concurrent.atomic.AtomicLong
import javax.inject.Inject
import javax.inject.Singleton
@@ -52,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"
@@ -68,6 +75,20 @@ 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.
+ private val eraseEpoch = AtomicLong(SecureRandom().nextLong())
+
var securityStatus = InitialStatus.NO
/**
@@ -76,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) {
@@ -89,8 +113,19 @@ 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()
+ 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
- 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 +183,14 @@ 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: phases 1 and 2
+ * of the erase ran in [AuthenticationManager.unlockWithPin], and [unlockWithRawKey] replaces
+ * 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 {
authenticationManager.unlockWithPin(pin)
@@ -162,6 +204,12 @@ class SecurityManager @Inject constructor(
SensitiveDataCleaner.clear(attempt.dek)
}
+ is PinUnlock.Erased -> try {
+ if (unlockWithRawKey(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 +339,86 @@ 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. 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
- val ok = secureDatabaseManager.unlockDatabase(dek, allowCreate = allowCreate)
- if (ok) {
- setSessionKey(dek)
- securityStatus = InitialStatus.MAIN
+ 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
+ }
+ 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 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() {
+ 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()
+ // 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()
+ }
+ }
+
+ /**
+ * 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 -----------------------------------------------------------------------
+
+ /**
+ * 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.
*/
@@ -351,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)
@@ -415,7 +535,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/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/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/BackupImporter.kt b/app/src/main/java/com/pasich/encly/data/backup/BackupImporter.kt
index 9ab18e2..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
@@ -3,26 +3,53 @@ 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
+import java.util.Locale
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)
+/** 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,
+ 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 +76,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)
@@ -61,25 +91,36 @@ 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) {
store.deleteAll()
- VaultSnapshot(emptyList(), emptyList(), emptyList())
+ VaultSnapshot(emptyList(), emptyList(), emptyList(), emptyList())
} else {
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 }
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,36 +129,90 @@ object BackupImporter {
tagsAdded = tagsAdded,
tasksAdded = newTasks.size,
skipped = total - added,
+ subtasksAdded = subtasksAdded,
)
}
}
- /** 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
}
+ /**
+ * 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,
+ )
+
+ /** 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 65388b0..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 }
@@ -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/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/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/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..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
@@ -5,7 +5,9 @@ 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.Task
import kotlinx.coroutines.flow.Flow
@@ -47,4 +49,73 @@ 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
+
+ /** 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
+
+ @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/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/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/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/HandoffStaging.kt b/app/src/main/java/com/pasich/encly/data/handoff/HandoffStaging.kt
new file mode 100644
index 0000000..96aef42
--- /dev/null
+++ b/app/src/main/java/com/pasich/encly/data/handoff/HandoffStaging.kt
@@ -0,0 +1,73 @@
+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 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.
+ */
+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 {
+ val zip = createStagingFile(dir)
+ try {
+ 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 {
+ release(dir, zip)
+ }
+ }
+
+ companion object {
+ const val DIR_NAME = "mynotes-handoff"
+ 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/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("""?[A-Za-z][^<>]*>""")
+ 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..721a064
--- /dev/null
+++ b/app/src/main/java/com/pasich/encly/data/handoff/MyNotesCallerVerifier.kt
@@ -0,0 +1,92 @@
+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`; 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,
+ 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
+
+ /** 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.
+ *
+ * - `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/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/data/model/Subtask.kt b/app/src/main/java/com/pasich/encly/data/model/Subtask.kt
new file mode 100644
index 0000000..6778b51
--- /dev/null
+++ b/app/src/main/java/com/pasich/encly/data/model/Subtask.kt
@@ -0,0 +1,35 @@
+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 = "",
+)
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..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
@@ -1,6 +1,7 @@
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.Task
import com.pasich.encly.domain.repository.TasksRepository
import kotlinx.coroutines.flow.Flow
@@ -45,4 +46,41 @@ class TasksRepositoryImpl @Inject constructor(private val databaseProvider: Data
override suspend fun deleteAllCompletedTasks(): Result = storageWrite(TAG, "deleteAllCompletedTasks") {
dao().deleteAllCompletedTasks()
}
+
+ 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)
+ }
+
+ 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..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,9 +1,11 @@
package com.pasich.encly.domain.repository
+import com.pasich.encly.data.model.Subtask
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 +19,29 @@ interface TasksRepository {
suspend fun updateTaskStatus(id: Long, isCompleted: Boolean, completedDate: Long?): Result
suspend fun deleteTaskById(id: Long): Result
suspend fun deleteAllCompletedTasks(): Result
+
+ /** 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>
+
+ /** 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/tasks/SubtaskTree.kt b/app/src/main/java/com/pasich/encly/presentation/components/tasks/SubtaskTree.kt
new file mode 100644
index 0000000..f5dd5a2
--- /dev/null
+++ b/app/src/main/java/com/pasich/encly/presentation/components/tasks/SubtaskTree.kt
@@ -0,0 +1,401 @@
+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
+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.MutableState
+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.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: 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,
+)
+
+/** 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 (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.moveRow(from, to) { moved, target ->
+ actions.onMove(taskId, moved, target) { rows.value = currentSubtasks }
+ }
+ }
+ Column(modifier = modifier) {
+ ReorderableColumn(
+ 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) },
+ )
+ // 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 {
+ 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),
+ customActions = moves,
+ dragging = dragging,
+ trailing = chevron.takeIf { index == 0 },
+ )
+ }
+ }
+ }
+ AddLeaf(
+ taskId,
+ edit,
+ actions,
+ enabled,
+ first = 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 moveActions(moveUp: (() -> Unit)?, moveDown: (() -> Unit)?): List {
+ val up = stringResource(R.string.tag_move_up)
+ val down = stringResource(R.string.tag_move_down)
+ 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. */
+@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) }
+ // 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)
+ 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) {
+ 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
+ if (activity?.isChangingConfigurations != true) 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
+ // 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
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..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,11 +14,25 @@ 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.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
@@ -23,16 +40,36 @@ import java.util.Date
private const val TASK_REMOVAL_ANIMATION_MS = 400
private const val TASK_TRANSLATION_X = 100f
-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,
) {
var isRemoving by remember(task.id) { mutableStateOf(false) }
@@ -48,7 +85,7 @@ fun TaskItem(
shouldComplete = shouldComplete,
onRemovingChange = { isRemoving = it },
onCompleteChange = { shouldComplete = it },
- onTaskToggle = onTaskToggle,
+ onTaskToggle = actions.onToggleTask,
)
val state = TaskCardState(
@@ -56,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, 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
@@ -87,34 +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(task: Task, state: TaskCardState, actions: TaskCardActions, modifier: Modifier = Modifier) {
+private fun TaskRow(
+ task: Task,
+ 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 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 = task.completedDate?.takeIf { task.isCompleted }?.let {
- stringResource(R.string.task_completed_at, dateFormat.format(Date(it)))
- },
+ 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..95a1eb2
--- /dev/null
+++ b/app/src/main/java/com/pasich/encly/presentation/designsystem/SubtaskTreeRows.kt
@@ -0,0 +1,358 @@
+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.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
+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], 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
+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,
+ customActions: List = emptyList(),
+ 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
+ },
+ )
+ .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(
+ 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..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
@@ -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,40 @@ 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
+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 +124,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 +146,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 +193,153 @@ 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; 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) {
+ val colors = MaterialTheme.colorScheme
+ val rtl = LocalLayoutDirection.current == LayoutDirection.Rtl
+ Canvas(
+ modifier = modifier
+ .widthIn(max = SEGMENT_BAR_MAX_WIDTH)
+ .fillMaxWidth()
+ .height(SEGMENT_DONE_HEIGHT)
+ .clipToBounds(),
+ ) {
+ if (done.isEmpty()) return@Canvas
+ 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())
+ 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
+ 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())
}
}
@@ -178,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/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..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
@@ -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,20 @@ fun AddTaskDialog(
title: String,
description: String?,
priority: Int,
+ subtasks: List?,
) -> Unit
)? = null,
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) }
+ // 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(
@@ -94,19 +108,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 +142,7 @@ fun AddTaskDialog(
}
EnclyBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) {
- TaskEditorContent(state, titleFocusRequester, actions)
+ TaskEditorContent(state, titleFocusRequester, actions, checklist)
}
LaunchedEffect(Unit) {
@@ -128,19 +151,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 +161,7 @@ private fun TaskEditorContent(
state: TaskEditorState,
titleFocusRequester: FocusRequester,
actions: TaskEditorActions,
+ checklist: SubtaskListState,
) {
val descriptionFocus = remember { FocusRequester() }
Column(
@@ -175,6 +189,8 @@ private fun TaskEditorContent(
keyboardActions = KeyboardActions(onDone = { actions.onSubmit() }),
fieldModifier = Modifier.focusRequester(descriptionFocus),
)
+ // 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
new file mode 100644
index 0000000..a0dff6d
--- /dev/null
+++ b/app/src/main/java/com/pasich/encly/presentation/dialogs/tasks/SubtaskEditor.kt
@@ -0,0 +1,285 @@
+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.data.model.Subtask
+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
+
+/** Longest sub-task title, in the sheet and in the list's inline field alike. */
+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
+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
+ }
+
+ /**
+ * 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)
+ }
+
+ 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)
+ // 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(),
+ ) {
+ 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(moves = moves, modifier = Modifier.draggableHandle())
+ }
+}
+
+/**
+ * The grip a row is dragged by; [modifier] carries the reorderable drag gesture, and [moves]
+ * the same moves for TalkBack.
+ */
+@Composable
+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
+ customActions = moves
+ },
+ ) {
+ 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/navigation/NavHost.kt b/app/src/main/java/com/pasich/encly/presentation/navigation/NavHost.kt
index 36cd895..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
@@ -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)
}
@@ -160,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(
@@ -169,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/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/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/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 5e2d478..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
@@ -43,36 +43,53 @@ 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(),
-) {
+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, 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)
+}
+
+@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)
+ 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 +134,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]. */
@@ -132,16 +157,24 @@ 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 erase since).
*/
-private fun NavHostController.leaveLockScreen(reveal: UnlockRevealState?, isSessionLocked: () -> Boolean) {
- val returnRoute = currentBackStackEntry?.savedStateHandle?.get(RelockReturn.RETURN_ROUTE)
+private fun NavHostController.leaveLockScreen(
+ reveal: UnlockRevealState?,
+ isSessionLocked: () -> Boolean,
+ canReopenNote: (savedEpoch: Long?) -> Boolean,
+) {
+ 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) navigate(returnRoute)
+ // Checked after the unlock: an erase is what changes the epoch.
+ if (returnRoute != null && canReopenNote(returnEpoch)) navigate(returnRoute)
}
}
}
@@ -149,15 +182,18 @@ private fun NavHostController.leaveLockScreen(reveal: UnlockRevealState?, isSess
/**
* 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/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 d8ea69f..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,17 +1,11 @@
package com.pasich.encly.presentation.screen
import android.content.Context
+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.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 +15,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,40 +67,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 (see OnPause).
+ 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(
@@ -124,6 +111,7 @@ fun TasksScreen(
onEditTask = viewModel::editTask,
onBackgroundSave = viewModel::saveDraftForBackground,
onDeleteTask = viewModel::deleteTask,
+ subtasks = viewModel.checklist,
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
)
}
@@ -135,6 +123,32 @@ 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 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
+ val activity = LocalActivity.current
+ DisposableEffect(lifecycleOwner, activity) {
+ val observer = LifecycleEventObserver { _, event ->
+ if (event == Lifecycle.Event.ON_PAUSE && activity?.isChangingConfigurations != true) 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) {
@@ -160,16 +174,10 @@ 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, and "Task deleted" with Undo, 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,
snackbarHostState: SnackbarHostState,
@@ -190,108 +198,28 @@ private suspend fun showTaskMessages(
if (result == SnackbarResult.ActionPerformed) viewModel.restoreTask(task)
}
}
-}
-
-/** 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
- },
- ),
+ 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)
}
}
- items(open, key = { it.id }) { task ->
- TaskItem(task = task, onTaskToggle = onToggle, onTaskClick = onOpen, modifier = Modifier.animateItem())
- }
- 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())
- }
- }
-}
-
-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,
+ 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)
}
}
}
-/** "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/screen/handoff/ImportFromMyNotesScreen.kt b/app/src/main/java/com/pasich/encly/presentation/screen/handoff/ImportFromMyNotesScreen.kt
new file mode 100644
index 0000000..ab381f8
--- /dev/null
+++ b/app/src/main/java/com/pasich/encly/presentation/screen/handoff/ImportFromMyNotesScreen.kt
@@ -0,0 +1,172 @@
+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()
+ // 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(
+ 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/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..436e2fc
--- /dev/null
+++ b/app/src/main/java/com/pasich/encly/presentation/screen/settings/WipePinScreen.kt
@@ -0,0 +1,366 @@
+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_after_erase), tone = CalloutTone.WARNING)
+ 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/ImportFromMyNotesViewModel.kt b/app/src/main/java/com/pasich/encly/presentation/viewmodel/ImportFromMyNotesViewModel.kt
new file mode 100644
index 0000000..0a18e90
--- /dev/null
+++ b/app/src/main/java/com/pasich/encly/presentation/viewmodel/ImportFromMyNotesViewModel.kt
@@ -0,0 +1,192 @@
+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
+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,
+ private val savedStateHandle: SavedStateHandle,
+) : 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. 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)
+ 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).also(::rememberDone)
+ } 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() {
+ // The staged copy is already gone: each load deletes its own.
+ handoff = null
+ }
+
+ /** 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
+
+ /**
+ * 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)
+ }
+}
+
+/** 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/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/LockViewModel.kt b/app/src/main/java/com/pasich/encly/presentation/viewmodel/LockViewModel.kt
index d8e3292..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)
@@ -58,6 +62,13 @@ 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 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(savedEpoch: Long?): Boolean = savedEpoch != null && savedEpoch == securityManager.eraseEpoch()
+
/** Unlocks with [pin], which is wiped. */
fun authenticatePin(pin: CharArray, onResult: (PinUnlockResult) -> Unit) {
launchUnlock(onResult) {
@@ -70,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/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/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..d3c7190
--- /dev/null
+++ b/app/src/main/java/com/pasich/encly/presentation/viewmodel/SubtaskDrafts.kt
@@ -0,0 +1,81 @@
+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)) }
+ }
+
+ /**
+ * [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.
+ */
+ 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..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
@@ -1,13 +1,20 @@
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.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
@@ -45,6 +52,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
@@ -55,8 +65,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,14 +88,21 @@ data class TasksUiState(
val selectedPriorityFilter: TaskFilter? = null,
val selectedCompletedFilter: TaskFilter? = null,
val filteredActiveTasks: List = emptyList(),
+ /** 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())
@@ -96,18 +121,76 @@ 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()
+
+ /**
+ * 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.
+ */
+ 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; a task deleted here is dropped from it.
+ */
+ 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>()
+
+ private var subtasksJob: Job? = null
+
/** 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
+ /** Whether the open trees restored from the saved state were checked against the first list. */
+ private var restoredTreesChecked = false
+
init {
observeTasks()
clearOnLock(lockEvents) {
tasksJob?.cancel()
+ subtasksJob?.cancel()
_uiState.value = TasksUiState()
_editingTask.value = null
+ _editingSubtasks.value = emptyList()
+ checklist = SubtaskListState(emptyList())
+ deletedSubtasks.clear()
_showAddTaskDialog.value = false
+ inlineEdit = null
}
}
@@ -118,30 +201,280 @@ class TasksViewModel @Inject constructor(
tasksRepository.getAllCompletedTasks(),
tasksRepository.getActiveTasksCount(),
tasksRepository.getCompletedTasksCount(),
- ) { activeTasks, completedTasks, activeCount, completedCount ->
+ tasksRepository.observeSubtasks(),
+ ) { activeTasks, completedTasks, activeCount, completedCount, subtasks ->
TaskFilterEngine.reduce(_uiState.value, activeTasks, completedTasks, activeCount, completedCount)
+ .copy(subtasks = subtasks.groupBy { it.taskId })
}.collect { newState ->
_uiState.value = newState
+ if (!restoredTreesChecked) {
+ restoredTreesChecked = true
+ forgetTreesOtherThan(newState)
+ }
+ dropGoneListState(newState)
+ }
+ }
+ }
+
+ /**
+ * 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 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: 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()
+ 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)
+ }
}
}
}
+ /** 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) failed(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 writeFailed = 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 (writeFailed) failed(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 PendingEvents.deleteSubtaskWithUndo(subtask: Subtask): Boolean {
+ val deleted = tasksRepository.deleteSubtask(subtask.id).isSuccess
+ if (deleted) {
+ events += PendingEvent.Deleted(subtask)
+ offerCompletionAfterDelete(subtask)
+ }
+ return deleted
+ }
+
+ private suspend fun PendingEvents.offerCompletionAfterDelete(deleted: Subtask) {
+ val after = tasksRepository.getSubtasks(deleted.taskId).getOrNull() ?: return
+ val task = findTask(deleted.taskId)
+ if (task != null && SubtaskDrafts.offersCompletion(task.isCompleted, after + deleted, after)) {
+ 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 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() {
+ commitInlineEdit()
+ subtasksJob?.cancel()
_editingTask.value = null
+ _editingSubtasks.value = emptyList()
+ checklist = SubtaskListState(emptyList())
_showAddTaskDialog.value = true
}
fun showEditTaskDialog(task: Task) {
+ commitInlineEdit()
+ 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
+ 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 {
+ val drafts = SubtaskDrafts.fromSubtasks(it)
+ _editingSubtasks.value = drafts
+ sheet.load(drafts)
+ }
+ }
}
fun hideAddTaskDialog() {
+ subtasksJob?.cancel()
_showAddTaskDialog.value = false
_editingTask.value = null
+ _editingSubtasks.value = emptyList()
+ checklist = SubtaskListState(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 +482,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 +524,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.
*
@@ -194,11 +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) {
+ 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 PendingEvents.persistDraftTask(draft: TaskDraft): Long? {
val description = draft.description.ifBlank { null }
val editing = _editingTask.value
if (editing == null) {
@@ -207,25 +600,23 @@ 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)
- }
+ .onFailure { failed(TaskOperationFailure.CREATE) }
+ .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 failed(TaskOperationFailure.UPDATE)
+ return editing.id.takeIf { saved }
}
fun toggleTaskCompletion(taskId: Long, isCompleted: Boolean) {
+ commitInlineEdit()
viewModelScope.launch {
if (updateTaskStatusUseCase(taskId, isCompleted).isFailure) {
_operationFailures.emit(TaskOperationFailure.STATUS_UPDATE)
@@ -233,9 +624,35 @@ 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()
+ 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)
+ }
+ }
+ }
+
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
+ forgetTrees(setOf(task.id))
hideAddTaskDialog()
_deletedTasks.emit(task)
} else {
@@ -244,10 +661,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)
}
}
@@ -255,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)
@@ -263,7 +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/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml
index b0e0d43..d7a6828 100644
--- a/app/src/main/res/values-de/strings.xml
+++ b/app/src/main/res/values-de/strings.xml
@@ -553,4 +553,21 @@
Tastatur ausblenden
Alle Daten löschen?
Löschen
+ Unteraufgaben
+ Unteraufgabe
+ Unteraufgabe hinzufügen
+ 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-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-de/strings_security.xml b/app/src/main/res/values-de/strings_security.xml
index 1e85213..e11dcac 100644
--- a/app/src/main/res/values-de/strings_security.xml
+++ b/app/src/main/res/values-de/strings_security.xml
@@ -68,4 +68,26 @@
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.
+ 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
+ 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.xml b/app/src/main/res/values-es/strings.xml
index 828ba95..a7e69fc 100644
--- a/app/src/main/res/values-es/strings.xml
+++ b/app/src/main/res/values-es/strings.xml
@@ -558,4 +558,22 @@
Ocultar teclado
¿Borrar todos los datos?
Borrar
+ Subtareas
+ Subtarea
+ Añadir subtarea
+ 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-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-es/strings_security.xml b/app/src/main/res/values-es/strings_security.xml
index 1a1ed21..06ae697 100644
--- a/app/src/main/res/values-es/strings_security.xml
+++ b/app/src/main/res/values-es/strings_security.xml
@@ -73,4 +73,26 @@
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.
+ 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
+ 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.xml b/app/src/main/res/values-fr/strings.xml
index ccd82a6..ac7e5e5 100644
--- a/app/src/main/res/values-fr/strings.xml
+++ b/app/src/main/res/values-fr/strings.xml
@@ -558,4 +558,22 @@
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
+ 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-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-fr/strings_security.xml b/app/src/main/res/values-fr/strings_security.xml
index 539c1f9..c28516b 100644
--- a/app/src/main/res/values-fr/strings_security.xml
+++ b/app/src/main/res/values-fr/strings_security.xml
@@ -73,4 +73,26 @@
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.
+ 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
+ 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.xml b/app/src/main/res/values-it/strings.xml
index ebc0a9d..900166c 100644
--- a/app/src/main/res/values-it/strings.xml
+++ b/app/src/main/res/values-it/strings.xml
@@ -558,4 +558,22 @@
Nascondi tastiera
Cancellare tutti i dati?
Cancella
+ Sottoattività
+ Sottoattività
+ Aggiungi sottoattività
+ 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-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-it/strings_security.xml b/app/src/main/res/values-it/strings_security.xml
index 8fc7c47..22e2043 100644
--- a/app/src/main/res/values-it/strings_security.xml
+++ b/app/src/main/res/values-it/strings_security.xml
@@ -73,4 +73,26 @@
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.
+ 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
+ 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.xml b/app/src/main/res/values-nl/strings.xml
index cbeb94b..55fc7e3 100644
--- a/app/src/main/res/values-nl/strings.xml
+++ b/app/src/main/res/values-nl/strings.xml
@@ -553,4 +553,21 @@
Toetsenbord verbergen
Alle gegevens wissen?
Wissen
+ Subtaken
+ Subtaak
+ Subtaak toevoegen
+ 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-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-nl/strings_security.xml b/app/src/main/res/values-nl/strings_security.xml
index de3b762..845d461 100644
--- a/app/src/main/res/values-nl/strings_security.xml
+++ b/app/src/main/res/values-nl/strings_security.xml
@@ -68,4 +68,26 @@
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.
+ 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
+ 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.xml b/app/src/main/res/values-pl/strings.xml
index 3e01d4d..329ea29 100644
--- a/app/src/main/res/values-pl/strings.xml
+++ b/app/src/main/res/values-pl/strings.xml
@@ -563,4 +563,23 @@
Ukryj klawiaturę
Wymazać wszystkie dane?
Wymaż
+ Podzadania
+ Podzadanie
+ Dodaj podzadanie
+ 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-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-pl/strings_security.xml b/app/src/main/res/values-pl/strings_security.xml
index b7ce815..c676477 100644
--- a/app/src/main/res/values-pl/strings_security.xml
+++ b/app/src/main/res/values-pl/strings_security.xml
@@ -78,4 +78,26 @@
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.
+ 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
+ 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.xml b/app/src/main/res/values-pt/strings.xml
index cec5a1c..44453b8 100644
--- a/app/src/main/res/values-pt/strings.xml
+++ b/app/src/main/res/values-pt/strings.xml
@@ -558,4 +558,22 @@
Ocultar teclado
Apagar todos os dados?
Apagar
+ Subtarefas
+ Subtarefa
+ Adicionar subtarefa
+ 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-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-pt/strings_security.xml b/app/src/main/res/values-pt/strings_security.xml
index 858820d..1b9da14 100644
--- a/app/src/main/res/values-pt/strings_security.xml
+++ b/app/src/main/res/values-pt/strings_security.xml
@@ -73,4 +73,26 @@
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.
+ 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
+ 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.xml b/app/src/main/res/values-uk/strings.xml
index 2a326cd..700c428 100644
--- a/app/src/main/res/values-uk/strings.xml
+++ b/app/src/main/res/values-uk/strings.xml
@@ -563,4 +563,23 @@
Сховати клавіатуру
Стерти всі дані?
Стерти
+ Підзавдання
+ Підзавдання
+ Додати підзавдання
+ Видалити підзавдання
+ Усі підзавдання виконано
+ Завершити завдання
+ Показати підзавдання
+ Сховати підзавдання
+ Розгорнуто
+ Згорнуто
+ Підзавдання видалено
+ Наступний крок: %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-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-uk/strings_security.xml b/app/src/main/res/values-uk/strings_security.xml
index bf7f301..9b1e242 100644
--- a/app/src/main/res/values-uk/strings_security.xml
+++ b/app/src/main/res/values-uk/strings_security.xml
@@ -78,4 +78,26 @@
PIN, фраза відновлення й відбиток відкривають кожен свій зашифрований слот. Відбиток потрібен щоразу.
Небезпечна зона
Утримуйте 5 секунд, щоб стерти
+
+ PIN для стирання
+ Другий PIN, що стирає нотатки, якщо ввести його на екрані блокування
+ Вимкнено після скидання ключа PIN. Задайте його знову, якщо користувалися ним.
+ Якщо вас змушують відкрити Encly, введіть PIN для стирання замість свого PIN. Encly непомітно зітре нотатки, завдання й мітки та відкриється як порожнє сховище. Працює лише на екрані блокування.
+ Після стирання Encly відкривається PIN для стирання: він стає PIN нового, порожнього сховища. Ваш старий 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.xml b/app/src/main/res/values/strings.xml
index 6b75428..09e3a3d 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -570,4 +570,21 @@
Hide keyboard
Erase all data?
Erase
+ Sub-tasks
+ Sub-task
+ Add sub-task
+ Delete sub-task
+ All sub-tasks are done
+ Complete task
+ 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/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/main/res/values/strings_security.xml b/app/src/main/res/values/strings_security.xml
index f11567f..8ab4c27 100644
--- a/app/src/main/res/values/strings_security.xml
+++ b/app/src/main/res/values/strings_security.xml
@@ -68,4 +68,26 @@
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.
+ 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
+ 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/ImportFromMyNotesActivityTest.kt b/app/src/test/java/com/pasich/encly/ImportFromMyNotesActivityTest.kt
new file mode 100644
index 0000000..0497bfa
--- /dev/null
+++ b/app/src/test/java/com/pasich/encly/ImportFromMyNotesActivityTest.kt
@@ -0,0 +1,73 @@
+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 com.pasich.encly.data.handoff.MyNotesCallerVerifier
+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?, intent: Intent = handoffIntent): Pair {
+ val controller = Robolectric.buildActivity(ImportFromMyNotesActivity::class.java, intent)
+ 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))
+ }
+
+ @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/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/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/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()
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
new file mode 100644
index 0000000..c29c112
--- /dev/null
+++ b/app/src/test/java/com/pasich/encly/core/security/WipePinTest.kt
@@ -0,0 +1,733 @@
+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.assertNotEquals
+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.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
+ * 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 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 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))
+ 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).wipeIfClosed()
+ 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))
+ assertEquals(WipeStage.CLEANUP, vault.auth.pendingWipe())
+ restarted.completePendingWipe()
+ 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.database, never()).wipeIfClosed()
+ verify(vault.biometric).disable()
+ assertNull(vault.auth.pendingWipe())
+ }
+
+ @Test
+ 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)))
+
+ // 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))
+ 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))
+ // 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()
+ assertEquals(erased, vault.security.eraseEpoch())
+ }
+
+ @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()
+ `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()
+ 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())
+ 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 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()
+ 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)
+ 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)
+
+ /** 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))
+ 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) { sweeps++ }
+ }
+
+ 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))
+ 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 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/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..42361c7
--- /dev/null
+++ b/app/src/test/java/com/pasich/encly/data/database/SubtasksDaoTest.kt
@@ -0,0 +1,199 @@
+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.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 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(
+ trip,
+ listOf(
+ Subtask(taskId = trip, title = "a", isCompleted = true),
+ Subtask(taskId = trip, title = "b"),
+ ),
+ )
+
+ 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
+ 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/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/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())
+ }
+}
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..3cb9a6d
--- /dev/null
+++ b/app/src/test/java/com/pasich/encly/data/handoff/HandoffStagingTest.kt
@@ -0,0 +1,104 @@
+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.Assert.assertTrue
+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 leftoversOfAKilledRunAreSwept() {
+ stagingDir.mkdirs()
+ 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/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("<"))
+ 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..ea8bee8
--- /dev/null
+++ b/app/src/test/java/com/pasich/encly/data/handoff/MyNotesCallerVerifierTest.kt
@@ -0,0 +1,94 @@
+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 theShippedPinsAreThePlayAndGitHubReleaseCertificates() {
+ assertEquals(
+ setOf(
+ "fd25d0a05a29c7f294f5b6c4230b482e9a55aba89908d64f2015a4acfdee81e3",
+ "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)
+ }
+}
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 })
+ }
+}
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/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/presentation/viewmodel/LockViewModelTest.kt b/app/src/test/java/com/pasich/encly/presentation/viewmodel/LockViewModelTest.kt
index b533d86..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
@@ -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.Default))
}
@After
@@ -74,6 +75,28 @@ 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())
+
+ // On the application scope, off the unlock's path.
+ verify(security, timeout(WAIT_MS)).completePendingWipe()
+ }
+
+ @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)
@@ -293,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
}
}
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/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..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,5 +1,6 @@
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.repository.TasksRepository
import com.pasich.encly.domain.usecase.task.UpdateTaskStatusUseCase
@@ -7,6 +8,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 +172,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 +198,31 @@ private class InMemoryTasksRepository : TasksRepository {
}
override suspend fun deleteAllCompletedTasks(): Result = Result.success(Unit)
+
+ 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 })
+
+ 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/TasksViewModelInlineSubtasksTest.kt b/app/src/test/java/com/pasich/encly/presentation/viewmodel/TasksViewModelInlineSubtasksTest.kt
new file mode 100644
index 0000000..19c2dcc
--- /dev/null
+++ b/app/src/test/java/com/pasich/encly/presentation/viewmodel/TasksViewModelInlineSubtasksTest.kt
@@ -0,0 +1,469 @@
+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 once loaded.
+ 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()
+ var resets = 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 })
+ assertEquals(0, resets)
+ }
+
+ @Test
+ fun aMoveAfterADeleteMovesTheRowThatWasDraggedOrNothing() = runTest(dispatcher) {
+ advanceUntilIdle()
+ // 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
+ 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
new file mode 100644
index 0000000..9727b86
--- /dev/null
+++ b/app/src/test/java/com/pasich/encly/presentation/viewmodel/TasksViewModelSubtasksTest.kt
@@ -0,0 +1,384 @@
+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
+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.assertSame
+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 theListGetsEachTasksSubtasksInOrder() = runTest(dispatcher) {
+ advanceUntilIdle()
+
+ 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
+ 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(before, viewModel.uiState.value.subtasks[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 })
+ }
+
+ @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)) {
+ 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/SecurityFakes.kt b/app/src/test/java/com/pasich/encly/testutil/SecurityFakes.kt
index 5c579ef..a8b23f0 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,44 @@ 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), [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 var key: ByteArray? = null
+ 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
private set
- override fun ensureKey() {
- if (key == null) reset()
+ fun hasKey(slot: PinKeySlot): Boolean = slot in keys
+
+ override fun ensureKey(slot: PinKeySlot): Boolean {
+ val missing = slot !in keys
+ if (missing) reset(slot)
+ return missing
}
- 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 {
- if (failing) throw PinFactorException(lost = false)
- val current = key
+ override fun mac(slot: PinKeySlot, data: ByteArray): ByteArray {
+ if (failing || slot in failingSlots) throw PinFactorException(lost = false)
+ val current = keys[slot]
if (lost || current == null) throw PinFactorException(lost = true)
calls++
return Mac.getInstance("HmacSHA256").run {
@@ -45,8 +58,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/testutil/TestTasksRepository.kt b/app/src/test/java/com/pasich/encly/testutil/TestTasksRepository.kt
index ec85137..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,21 +1,29 @@
package com.pasich.encly.testutil
+import com.pasich.encly.data.model.Subtask
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 +57,73 @@ 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 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> =
+ Result.success(subtasks.value.filter { it.taskId == taskId }.sortedBy { it.position })
+
+ override suspend fun saveSubtasks(taskId: Long, subtasks: List