Skip to content

feat: sub-tasks, wipe PIN and import from My Notes - #56

Merged
pasichDev merged 19 commits into
mainfrom
feat/subtasks-wipe-pin-my-notes-import
Oct 5, 2026
Merged

pasichDev merged 19 commits into
mainfrom
feat/subtasks-wipe-pin-my-notes-import

Conversation

@pasichDev

@pasichDev pasichDev commented Oct 5, 2026 •

Copy link
Copy Markdown
Owner

Merge order: this → Encly release → pasichDev/MyNotes#167.

What and why

One release with three features, plus the fixes from a full review of the branch (see Review fixes).

Sub-tasks (#55)

  • Tasks list ("Next step"):
    • A task with sub-tasks shows its next open sub-task as one tree leaf, plus a segment bar of its progress (no counts). Ticking it saves at once, and the following one slides up. The last one (ticked or deleted) offers "Complete task".
    • A tap on the task opens the whole tree with an add leaf ("First step" on a task without any). In the tree you can rename inline, delete with Undo, long-press-drag to reorder, and use Move up/down in TalkBack. Back closes the field, then folds the tree.
    • The pencil is the one way into the task sheet.
  • Room: new subtasks table (FK cascade, unique uid + triggers), DB_VERSION 3 → 4 via MIGRATION_3_4, schema 4.json exported.
  • Backup schema 2 → 3. Schema 1/2 still restore. MERGE adds missing sub-tasks by uid.

Wipe PIN (#52)

  • Optional second PIN. Typed on the lock screen, it silently erases the vault and opens an empty one. Settings → Security → Wipe PIN.
  • Nothing changes for existing users. pin.slot, its KDF and ENCLYVS3 stay; a random decoy pin.wipe.slot is added at startup.
  • The real and decoy wipe slots can't be told apart. The wipe slot has no salt of its own (salt and AAD come from pin.slot). One PBKDF2 and Keystore HMAC run per attempt feeds two HKDF keys, and both slots are always tried.
  • Crypto-erase in phases: new key on the other Keystore alias (A/B), atomic store edit with wipe.pending, then old DB and keys deleted. Kill-safe. The user never lands on onboarding or a recovery screen.
  • configurePin now keeps the salt. A Keystore key reset turns the wipe PIN off and Settings says so.

Import from My Notes (#46)

  • Pinned My Notes certificates: Play App Signing (fd25d0a0…81e3, from Play Console) and the GitHub/F-Droid release key (03f2b8c7…483f, also the Play upload key).
  • Exported ImportFromMyNotesActivity, for com.pasich.encly.action.IMPORT_FROM_MY_NOTES only.
  • Caller trust check before the URI is touched: package com.pasich.mynotes plus a pinned certificate. API 26–27 use a single GET_SIGNATURES signer.
  • Strict reader with size, record, depth and zip-bomb limits. The temp copy is always deleted.
  • Unlock → preview → confirm → import → result. Editor.js and inline HTML are mapped to Encly blocks and plain text. Tags are matched by uid or by name (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

  • Backups on an older Encly: a schema-3 backup is reported as "update the app" (the decoder is strict by design). Installing an older build over DB v4 is not possible.
  • Tasks have no trash: that part of Sub-tasks for tasks #55 became cascade delete + Undo.
  • No weak-PIN check: the normal PIN has none either.
  • Wipe slot size: 61 bytes, not the size of pin.slot. Real and decoy are identical.
  • Wipe PIN visibility: Settings never shows whether a wipe PIN is set. Changing the PIN to the current wipe PIN turns the wipe PIN off.
  • Wipe PIN outside the lock screen: inside an open vault (settings, export) it counts as a wrong PIN.
  • Import inside the auto-lock grace: no extra PIN prompt, but the preview still needs confirmation.

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, untouched pin.slot, simulated downgrade, kill between phases, lockout, A↔B, JVM timing bound;
    • data/handoff/*: mapper, reader, verifier incl. API < 28, staging, HTML; ImportFromMyNotesActivityTest;
    • extended BackupPayloadCodecTest, BackupImporterTest, security settings and screen tests.
  • ./gradlew spotlessCheck :app:detekt :app:lintFdroidRelease: pass
  • ./gradlew :app:assembleFdroidRelease; merged release manifest has no INTERNET
  • Checked on a device: Pixel 7a (Android 16), debug build, driven over adb/uiautomator.
    • Sub-tasks: next step and bar; tick advances to the next one; expand and add leaf; inline add; delete and Undo back in place; rotation with the sheet open, then Save (all rows kept); rotation with the inline field open (text kept, nothing saved).
    • Wipe PIN: set it, erase from the lock screen into an empty vault; after a cold start the old PIN is refused and the wipe PIN opens.
    • Real hand-off from My Notes Debug: trust checks pass, unlock, preview, 16 notes and 2 tags imported, counts shown back in My Notes, temp ZIP deleted.

Not verified:

  • TalkBack actions and long-press drag on a device;
  • unlock timing on a device, wipe vs normal (Keystore key generation on the wipe path, no padding);
  • real AndroidKeyStore alias B;
  • install-old-APK downgrade;
  • AppDatabaseSchemaTest migration tests (written, not run: no emulator);
  • the UI on a device;
  • API 26–27 signature path.

Known edge case: downgrade after a wipe, set a new PIN on the old build, then upgrade → WrongPin instead of KeyLost. 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

  • No new permission, and in particular no INTERNET permission.
  • No network, analytics, telemetry or proprietary (Google Play Services) dependency.
  • No new path that puts plaintext outside the vault: the only plaintext is the app-private temp copy of the incoming hand-off ZIP, deleted in finally.
  • If key derivation, the SQLCipher passphrase, the vault slots or any on-disk format changed: existing installs still open (migration added and tested), and SECURITY.md is updated.
  • If the Room schema changed: DB_VERSION bumped, a migration added, the new app/schemas export committed, and AppDatabaseSchemaTest extended.
  • User-visible changes are listed under [Unreleased] in CHANGELOG.md.

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
@github-actions

github-actions Bot commented Oct 5, 2026 •

Copy link
Copy Markdown

Coverage (core/security)

Overall Project 74.19% -1.57% 🍏
Files changed 93.5% 🍏

File Coverage
AuthenticationManager.kt 98.46% -1.35% 🍏
VaultStore.kt 95.41% 🍏
SeedPhraseManager.kt 93.81% 🍏
SessionLockManager.kt 88.6% 🍏
SecurityManager.kt 77.86% -0.61% 🍏
PinHardwareFactor.kt 18.06% -22.1% 🍏
BiometricManager.kt 8.55% 🍏

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
pasichDev marked this pull request as ready for review October 5, 2026 10:59
@pasichDev
pasichDev merged commit 689e5cd into main Oct 5, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Sub-tasks for tasks Wipe PIN: a second PIN that silently erases the vault Import notes from My Notes (one-way hand-off)

1 participant