feat: sub-tasks, wipe PIN and import from My Notes - #56
Merged
Merged
Conversation
A task can now hold an ordered checklist of sub-tasks, one level deep. They are added, edited, reordered and removed in the task sheet, and the task tile shows progress (2/5). Completing a task never ticks its sub-tasks; ticking the last open one offers "Complete task" in a snackbar instead of completing it silently. Deleting a task removes its sub-tasks with it, and Undo brings them back. Sub-tasks live in a new subtasks table (FK to tasks with ON DELETE CASCADE, unique uid kept by the same triggers as the other tables). The database goes from version 3 to 4 through an explicit migration that matches the exported schema. Backups move to schema 3: each task carries its sub-tasks. Schema 1 and 2 files still restore (with no sub-tasks); MERGE adds the sub-tasks an existing task is missing, by uid. Because decoding is strict, an older Encly reports a schema-3 backup as "update the app", and installing an older build over database version 4 is not possible. Both are noted in the CHANGELOG. Refs: #55
A second, optional PIN for when someone forces you to open Encly. Typed on the lock screen like the normal PIN, it destroys the vault and opens an empty one, with nothing on screen to show that anything was deleted. It is off by default; Settings -> Security -> Wipe PIN sets or removes it after the real PIN, and warns that a fingerprint can be forced too. Nothing changes for anyone who never sets it. pin.slot, its KDF and the store format stay as they are. Every install gets a pin.wipe.slot: a random decoy until a wipe PIN is set, the same size and version either way, and without a salt of its own (the salt comes from pin.slot), so the store looks the same with and without the feature. One PBKDF2 and Keystore HMAC run per attempt feeds two HKDF keys, and both slots are always tried. Changing the PIN now keeps the salt so the wipe PIN survives; if the device-bound key had to be reset, the wipe PIN is turned off and Settings says so. The erase is a crypto-erase in phases: a new PIN key under the other Keystore alias (A/B), a new DEK and slots, then one atomic store edit that drops the recovery, backup, biometric and lockout entries and sets wipe.pending, then the old database and keys are deleted. A kill at any point is finished on the next start; the user never sees onboarding or a recovery screen. Exported backups are not touched. SECURITY.md describes what this does and does not protect against and replaces "There is no auto-wipe". Refs: #52
Encly is the successor of My Notes, so it can now receive everything My Notes hands over in one step. My Notes starts com.pasich.encly.action.IMPORT_FROM_MY_NOTES for result with a content:// URI to a ZIP holding handoff.json (mynotes-handoff, schema 1). The hand-off is one way: Encly exposes nothing readable to My Notes. Before anything is read, the caller must be com.pasich.mynotes signed with a pinned certificate (hasSigningCertificate on API 28+, a single GET_SIGNATURES signer on 26-27). The ZIP is copied to a private temp file, read strictly with size, record, depth and zip-bomb limits, and deleted afterwards whatever happens. The vault must be unlocked, and a preview says what comes over and what stays behind (attachments, pins) before anything is imported. My Notes data is mapped here, so the block format stays private to Encly: Editor.js paragraphs, headers, lists, checklists and delimiters become Encly blocks, inline HTML becomes plain text, trashed notes go to the trash, task categories become tags. The import goes through the backup MERGE path in one transaction; tags are matched by uid or by name, and a repeated hand-off adds nothing. The result goes back to My Notes as counts or a reason code. The Play App Signing certificate of My Notes still has to be added to the pinned set before release. Refs: #46
The My Notes import moved the lock screen's navigation into LockExits, and the wipe PIN made leaving the lock screen skip reopening the note that was open before a wipe-PIN unlock. The NavHost lock screen now reads canReopenNote from the same LockViewModel instance and passes it on, so both changes hold together. Refs: #52, #46
Coverage (core/security)
|
Builds of My Notes installed from Google Play are signed with the Play App Signing key, not with the release key of the GitHub and F-Droid builds (which Play only uses as the upload key). Pin both certificates, so the hand-off from the Play build is not refused as untrusted_caller. Refs: #46
… place
On the Tasks list an open task with sub-tasks now shows its next step,
the first open sub-task, as one tree leaf under it, plus a segment bar
of its progress instead of a "2/5" count. Ticking the next step saves it
at once and the following one slides up into its place; ticking (or
deleting) the last open one offers "Complete task". A task without
sub-tasks looks as before.
A tap on the task opens the whole tree, done ones struck through, ending
with an add leaf ("First step" on a task without any). In the tree a
sub-task ticks in place, a tap on its title renames it, clearing the
title or its cross deletes it with Undo, and a long press drags it into
a new place, with Move up/down for TalkBack. Back closes an open field,
then folds the tree. Open trees survive rotation, and an unsaved field
is saved when the app goes to the background. The pencil is now the one
way into the task sheet, where the sub-task block sits right under the
description.
Refs: #55
After an erase the wipe PIN is the PIN of the new, empty vault and the old PIN is wrong, so whoever saw it typed can open Encly with it again and sees nothing unusual. The wipe PIN screen did not say so, and it read as a broken PIN on the first try. It now says it in a warning callout, in all nine languages. A test covers the cold start after an erase: the wipe PIN opens the empty vault with the key it was created with, and the old PIN is refused. Refs: #52
A debug build installs next to the release app under its own application id, with the same name and icon, so the two were easy to mix up on a test phone.
…anew Setting a PIN makes the device-bound PIN key first if it is missing. A key the system invalidated was already treated as a reset, which turns the wipe PIN off and says so in Settings. A key that was simply gone (deleted rather than invalidated) was created silently instead: the old wipe slot was kept although it was sealed with the lost key, so the wipe PIN stopped working and nothing said so. PinHardwareFactor.ensureKey now reports whether it had to create the key, and configurePin counts that as a reset. A test deletes the key of a vault with a wipe PIN and checks that the next PIN turns the wipe PIN off with the notice. Refs: #52
After an unlock, the note that was open when the app re-locked was reopened unless the open session was the one the wipe PIN had made. That guard was cleared by every lock, so a re-lock after the erase, an erase whose own open failed and was retried with the PIN of the new slot, going to the background while the vault was revealed, or an erase on the My Notes hand-off's lock screen could still reopen the old note's id in the empty vault: an empty editor that gives the erase away. SecurityManager now keeps an erase epoch. It changes whenever an erase creates the empty database, never on lock or unlock, and starts at a random value in each process. The editor records the epoch with its note, the re-lock copies both onto the lock screen (and forgets them when the screen left was not an editor), and the unlock reopens the note only while the epoch is unchanged. SessionLockManager counts published unlocks; MainActivity notes the count when it stops and, if the vault was unlocked on another lock screen meanwhile, shows the lock screen or a fresh Home instead of screens whose ViewModels have dropped their content. After a process restart the note is no longer reopened, which the changelog says. Refs: #52
…writes Several gaps around the erase's last steps: - A recreated activity (a rotation while the erase ran) resolves the startup state again and finished the pending erase at the same time as the unlock was creating the empty database: the open-check passed before the database existed, and the delete then removed the new one. Creating, opening and deleting the database during an erase are now serialized under one lock, the startup delete only happens while the database is closed (checked under the database manager's own lock), and the startup finish runs once per process. - Recording that the empty database exists was not checked. If that store write failed, the next start took the new vault for the old one and deleted it with everything added since. The unlock now closes the database again and fails; the next one creates it anew. - An erase that failed after making the new Keystore key (deriving with it, or the store write) left that second key behind while the wipe PIN stayed set. It is deleted again, and a key under the second alias left by a process that died before the erase was recorded is deleted at the next start (only while the first alias is active and nothing is pending). - The wipe PIN's unlock waited for deleting the old PIN key, the biometric key and the export date. Those now run once the vault is shown. The remaining difference to a normal unlock (a new Keystore key, two synced store writes, creating the database) is stated in SECURITY.md, as is the downgrade case where an older build's PIN change silently turns the wipe PIN off. Tests cover the interleaving with a blocked database open, the failed record, both failed erases, the stray key and the deferred cleanup. Refs: #52
The segment bar gives each sub-task a segment of at least 1 dp, 1 dp apart, and did not clip. With more than about 80 sub-tasks the segments no longer fit in the bar's 160 dp and were drawn past it. When they do not fit, the bar now draws as many segments as fit, done ones first, in the same proportion of done to open (rounded, with at least one of each kind there is), and clips to its bounds. A unit test checks the layout math. Refs: #55
…d reorders Review findings on the sub-tasks of the Tasks list and the task sheet: - Rotating with the sheet open saved its checklist in the background, but the recreated sheet started from the checklist loaded when it opened, and Save then wrote that back: new rows were deleted, deleted ones came back, renames and ticks were reverted. The sheet's checklist now lives in TasksViewModel, and a background save gives the rows it stored their ids and uids (and publishes the stored checklist), so the next save updates them. - Rotating while typing a new sub-task in the list's inline field saved the half-typed title and cleared the field, through the pause flush and the field's focus loss. Both now skip a configuration change; the field lives in the ViewModel and comes back with its text. Backgrounding still saves it on pause, well before the re-lock. - Snackbar events were emitted while the sub-task lock was held, so a backlog of snackbars held every later sub-task write, the sheet's load and the background save. They are now sent after the lock is released, none dropped. - A double tap on the last open checkbox offered "Complete task" twice: the state before the tick was derived from the state after it. It is now read before the write, and a tick already stored is skipped. - Move up/Move down sat on a row TalkBack never focuses. They are now on the sub-task's title in the tree and on the drag handle in the sheet. - A reorder passed positions, which point at another row when a delete is in flight. It now passes the moved row's id and the id of the row whose place it takes, and the tree shows the stored order again when the move is not stored. - Completing or reopening a task passes through a list update where it is in neither list, which folded its open tree. Open trees are now dropped only for tasks deleted here, and once against the first list for ids restored from the saved state. Each has a test: ViewModel tests for the sheet (new and edited task), the snackbar backlog, the double tap, the reorders and the completed tree, a Robolectric recreation test for the inline field, and Compose tests for the TalkBack actions. Refs: #55
The hand-off trusted the calling package and its certificate. That package names the app the result goes to, not the app that wrote the intent: My Notes starts a file picker for a result in its own import, and a malicious picker could forward that request into Encly with FLAG_ACTIVITY_FORWARD_RESULT. Encly would then see My Notes as the caller of the picker's intent and read the picker's URI. Before the URI is read, the activity now also refuses a forwarded request, an app other than My Notes where Android 14+ names the launching app, and a URI that is not from My Notes' own FileProvider (its authority, and a provider that PackageManager finds in the My Notes package). The package and authority are defined together in MyNotesCallerVerifier. SECURITY.md no longer claims the caller cannot be forged and lists the checks. Robolectric tests cover each refusal. Refs: #46
The test of the cleanup that runs after a PIN unlock gave LockViewModel an unconfined scope. A launch on Dispatchers.Unconfined from inside another unconfined coroutine is queued until that one returns, so the test sometimes checked before the cleanup had run. It now uses a background scope and waits for the call, like the real application scope. Refs: #52
… finished Three smaller gaps in the My Notes hand-off: - My Notes' ZIP (plaintext) was staged under one fixed name and only deleted by the next hand-off, so a killed process left it in the cache until then, even across a wipe-PIN erase, and two hand-offs at once shared the file. Each load now copies into a file of its own and deletes only that one. The staging directory is swept at app start (off the main thread) and when a wipe-PIN erase finishes, never touching a file a load of the same process is reading. - When the lock screen found that nothing can open the vault any more, the failure was set but the screen kept showing the lock screen while the vault stayed locked. A failure now shows even while locked. - After process death on the Done page, the activity started over: the preview came back and a second confirm reported zeros. The counts are kept in the saved state, and a restored hand-off shows them again without reading the URI. SECURITY.md and PRIVACY.md say when the staged copy is deleted. Tests cover the per-load files and the sweep, the failure on the lock screen, the restored Done page and the sweep at the end of an erase. Refs: #46
Rotating the phone, switching dark mode or changing the font size recreates the activity, which resolved the start status again and found a committed vault: it re-locked it and showed the PIN pad, dropping an open task sheet or inline field. The comment there already meant only a vault that is not open; the check was missing. While this process holds the vault open (the session key and the database), the start status is now the open vault. Refs: #55
A rotation hides the keyboard just before it disposes the inline field, and a hidden keyboard closes the field and saves its text. Typing "Tea" and turning the phone stored "Tea" as a sub-task and opened an empty field. The keyboard now has to stay hidden for a moment before it closes the field; a field disposed by the rotation cancels that and comes back open with its text. Back and the keyboard's hide key still close it. Refs: #55
…Notes Bumps the version to 2.1.0 (versionCode 20100), moves the Unreleased notes under 2.1.0 and adds the 2.1.0 release notes for the stores in all nine store languages. The release changes the database (version 4) and the backup format (schema 3), which older versions cannot open; the CHANGELOG says so under Compatibility.
pasichDev
marked this pull request as ready for review
October 5, 2026 10:59
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What and why
One release with three features, plus the fixes from a full review of the branch (see Review fixes).
Sub-tasks (#55)
subtaskstable (FK cascade, uniqueuid+ triggers),DB_VERSION3 → 4 viaMIGRATION_3_4, schema4.jsonexported.Wipe PIN (#52)
pin.slot, its KDF andENCLYVS3stay; a random decoypin.wipe.slotis added at startup.pin.slot). One PBKDF2 and Keystore HMAC run per attempt feeds two HKDF keys, and both slots are always tried.wipe.pending, then old DB and keys deleted. Kill-safe. The user never lands on onboarding or a recovery screen.configurePinnow keeps the salt. A Keystore key reset turns the wipe PIN off and Settings says so.Import from My Notes (#46)
fd25d0a0…81e3, from Play Console) and the GitHub/F-Droid release key (03f2b8c7…483f, also the Play upload key).ImportFromMyNotesActivity, forcom.pasich.encly.action.IMPORT_FROM_MY_NOTESonly.com.pasich.mynotesplus a pinned certificate. API 26–27 use a singleGET_SIGNATURESsigner.TagMatch.UID_OR_NAME). A repeated hand-off adds nothing.Strings in all 9 locales. CHANGELOG, SECURITY.md and PRIVACY.md are updated.
Differences from the issue texts
pin.slot. Real and decoy are identical.Closes #46, closes #52, closes #55.
How it was tested
./gradlew :app:testFdroidDebugUnitTest :app:koverVerifyFdroidDebug: pass, 1175 tests on the final commit. New suites:SubtasksDaoTest(Robolectric + real Room),SubtaskDraftsTest,TasksViewModelSubtasksTest;WipePinTest(28): store shape with and without a wipe PIN, untouchedpin.slot, simulated downgrade, kill between phases, lockout, A↔B, JVM timing bound;data/handoff/*: mapper, reader, verifier incl. API < 28, staging, HTML;ImportFromMyNotesActivityTest;BackupPayloadCodecTest,BackupImporterTest, security settings and screen tests../gradlew spotlessCheck :app:detekt :app:lintFdroidRelease: pass./gradlew :app:assembleFdroidRelease; merged release manifest has noINTERNETadb/uiautomator.Not verified:
AppDatabaseSchemaTestmigration tests (written, not run: no emulator);Known edge case: downgrade after a wipe, set a new PIN on the old build, then upgrade →
WrongPininstead ofKeyLost. Only reachable if a recovery phrase was added after the wipe. Downgrading and setting a PIN on an older build also silently disables a wipe PIN (documented in SECURITY.md).Security and data checklist
INTERNETpermission.finally.DB_VERSIONbumped, a migration added, the newapp/schemasexport committed, andAppDatabaseSchemaTestextended.[Unreleased]in CHANGELOG.md.