Skip to content

Mobile: Resolves #16002: Add note lock UI - #4

Closed
keshav0479 wants to merge 4 commits into
gsoc-local-note-encryption-note-lock-uifrom
gsoc-local-note-encryption-mobile-safe-behavior
Closed

Mobile: Resolves #16002: Add note lock UI#4
keshav0479 wants to merge 4 commits into
gsoc-local-note-encryption-note-lock-uifrom
gsoc-local-note-encryption-mobile-safe-behavior

Conversation

@keshav0479

@keshav0479 keshav0479 commented Jul 21, 2026

Copy link
Copy Markdown
Owner

Summary

Adds the mobile note lock UI behind the existing feature flag. Issue: laurent22#16002. Stacked on laurent22#15961.

Enable/Disable encryption and Lock encrypted notes live in the note screen menu (no note list actions on mobile, long press is multi-select). Encrypted notes show a padlock in the note list. A locked note opens read-only with the locked panel instead of the body. Enabling with no password redirects to config (dead route until laurent22#15963 merges), and enabling while locked shows the unlock form in a modal.

The mobile note screen drives everything from its state note, so the shared load/save path handles the gating. A locked note keeps its encrypted body in state while the session is locked (the UI hides it), decrypts on unlock, and pending saves re-encrypt with the key captured at load. Saves write lock state, encrypted body and extracted resource ids together and read the latest lock state at execution time. Viewer checkbox saves go through the gated path too, and a note deleted while open is recreated without losing its decrypted state.

Share is disabled for locked notes while the session is locked or the note is undecryptable; sharing an unlocked note uses a gated load.
Enable/disable now persist through the note screen's scheduled save (event-driven, matching desktop).

With the flag off, the note screen behaves exactly as before.

Testing

  • yarn tsc
  • yarn workspace @joplin/lib test (note-screen-shared, noteLock)
  • yarn workspace @joplin/app-mobile test Note.test
  • manually tested on the web app: enable/disable, wrong and correct password, manual and auto lock, first-time setup prompt, enabling on a new empty note while locked, typing then locking with the edit saved encrypted, flag off behaving as before

Video

Full run-through:

video_20260721_175500.mp4

AI Assistance Disclosure

I used AI tools while working on this PR for code suggestions and review, checking scope and tests, and drafting parts of this description, including the disclosure. I reviewed the final changes and reran the tests listed above myself.

Repository owner deleted a comment from github-actions Bot Jul 21, 2026
Repository owner deleted a comment from github-actions Bot Jul 21, 2026
const isProvisionalNote = comp.props.provisionalNoteIds.includes(comp.props.noteId);

const note = await Note.load(comp.props.noteId);
let note = await Note.load(comp.props.noteId);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Avoid calling a full load twice. Do a load for just the is_locked field first to get the value

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done, is_locked-only load first, full load happens once

note = await Note.load(comp.props.noteId, { useNoteLock: true });
noteLockKey = NoteLockSession.instance().decryptedKey();
} else {
// Both note and lastSavedNote carry the same blank body, so a diff-based save can

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This does not guarantee safety. Clearing the body here is still a risk of data loss. In order to prevent the cipher text ever showing in the note, what I think you can do is in Note.tsx, in the render function, override the bodyComponent variable when the body should be blocked, with a component which is just a blank area (the background colour should match the theme).

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

removed the blanking, both state notes keep the encrypted body so a diff save can't write it, and the panel hides it in the ui

// The recreate save strips the decrypted-state marker, but the recreated body is the
// in-memory plaintext, so the marker must be restored for a gated save.
const handleNoteDeletedKeepingDecryptedState_ = async (note: NoteEntity) => {
const recreatedNote = await shared.handleNoteDeletedWhileEditing_(note);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The wrapper function is unnecessary and currently poses a risk of persisting the body unencrypted while is_locked = 1, so if the app crashes immediately after, the note will be unreadable in the UI because it will fail to decrypt (but it is recoverable in the db). handleNoteDeletedWhileEditing_ needs to be altered directly to use both a gated save and a gated load, then it removes the save risk and populates isDecrypted by itself.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wrapper's gone,handleNoteDeletedWhileEditing_ does a gated save and gated load directly when the flag is on

});
}

private async flushSavesAndAutoLock_(noteId: string) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's no need to put this into a function. flushSavesAndAutoLock_ is only used in 1 place

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

inlined

private async flushSavesAndAutoLock_(noteId: string) {
await this.saveActionQueue(noteId).processAllNow();
// Auto lock only after the queue drains, so a pending locked-note save can still encrypt.
if (isNoteLockEnabled() && Setting.value('noteLock.lockOnNoteSwitch')) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why are you doing this on unmount instead of on load? Was it just more practical to do so?

If you follow a link to another locked note within a locked note, will the unlock ui show with the setting enabled? If it doesn't, it should really be consistent with desktop (locks on every navigation), and be made to lock on load instead. But if unmount does fire every time when navigating through notes, then it should be fine to leave as is.

Also, if you toggle between view and edit mode, and switch between markdown and rich text editor, does the note stay unlocked, or does it show the unlock ui? The expectation is it should stay unlocked with those switches.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

unmount does fire on note-to-note navigation, screen goes back then forward when the note id changes, so following a link to another locked note shows the unlock ui. view/edit toggle and switching editors both keep it unlocked

// The lock state may change between scheduling and execution (e.g. encryption enabled
// from the note menu), so the save uses the latest values.
note.is_locked = comp.state.note.is_locked;
if ('isDecrypted' in comp.state.note) (note as Record<string, unknown>).isDecrypted = (comp.state.note as Record<string, unknown>).isDecrypted;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ditch the if condition and make isDecrypted always get set to the value on the comp.state.note here

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

// An ungated partial body save would persist the plaintext of a locked note (e.g. a
// checkbox toggled in the viewer), so the body goes through a gated save instead.
toSave.is_locked = note.is_locked;
if ('isDecrypted' in note) toSave.isDecrypted = (note as Record<string, unknown>).isDecrypted;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Replace with:
(toSave as Record<string, unknown>).isDecrypted = (note as Record<string, unknown>).isDecrypted;

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

const saved = await Note.save(toSave) as Record<string, unknown>;

let saved: Record<string, unknown>;
if (isNoteLockEnabled() && name === 'body' && NoteLockNote.isLocked(note)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please put this whole if/else block into a new private function called saveNotePartial which returns saved

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done. one note: right after disabling, a checkbox toggle could still do an ungated body save while the db row hasn't flipped yet (until the scheduled save lands), same transient window class as the event-driven flow, left as is

// Without this, the state update below would revert a lock state change made during
// the save, and the already scheduled lock save would pick up the reverted value.
note.is_locked = stateNote.is_locked;
if ('isDecrypted' in stateNote) (note as Record<string, unknown>).isDecrypted = (stateNote as Record<string, unknown>).isDecrypted;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ditch the if condition and make isDecrypted always get set to the value on the stateNote here

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

if (isNoteLockEnabled() && comp.state.note?.id === note.id) {
// The lock state may have changed during the save - keep the latest value in the state
// note (but not in lastSavedNote, so the next save still detects the change).
stateNote.is_locked = comp.state.note.is_locked;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also add this here too:
(stateNote as Record<string, unknown>).isDecrypted = (comp.state.note as Record<string, unknown>).isDecrypted;

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

@mrjo118

mrjo118 commented Jul 21, 2026

Copy link
Copy Markdown

The mobile app additionally has a share function in the hamburger menu. Similar to print, you should make it disabled for locked notes when the session is locked or the note is undecryptable, but enabled when the session is unlocked, and perform a gated loading before outputting the shared contents

@keshav0479
keshav0479 force-pushed the gsoc-local-note-encryption-mobile-safe-behavior branch from 396c2f2 to daf2fe4 Compare July 22, 2026 11:35
Repository owner deleted a comment from github-actions Bot Jul 22, 2026
@mrjo118

mrjo118 commented Jul 22, 2026

Copy link
Copy Markdown

Don't force push please, now I have to review this all over again

@mrjo118

mrjo118 commented Jul 22, 2026

Copy link
Copy Markdown

Please look into how to undo rebase using reflog, as this quite a mess now and I've lost the history of what you had before. Please backup your current changes to a new branch, the revert the rebase and force push it back to how it was

@keshav0479

Copy link
Copy Markdown
Owner Author

Don't force push please, now I have to review this all over again

sorry, that was the rebase for the note-screen-shared move, i can force push it back to the exact commit you reviewed so your comments reanchor, or leave it as is,either way no more force pushes after that, everything else comes as normal commits

@mrjo118

mrjo118 commented Jul 22, 2026

Copy link
Copy Markdown

Backup you branch and revert to exactly how it was. There is no need to rebase, do merges instead

@keshav0479
keshav0479 force-pushed the gsoc-local-note-encryption-mobile-safe-behavior branch from daf2fe4 to 396c2f2 Compare July 22, 2026 12:34
Repository owner deleted a comment from github-actions Bot Jul 22, 2026
@mrjo118

mrjo118 commented Jul 22, 2026

Copy link
Copy Markdown

An additional suggestion for change:
I noticed the prompt which is shown when you enable encryption while the session is locked covers the entire screen. As it's not really necessary to mask anything in this scenario, and the embedded overlay covers the opening locked note scenario, I think you should make the prompt just a normal modal which only partially covers the screen, and has a translucent mask over the rest. This is more consistent with the behaviour on desktop, and also with modals in general on the mobile app.

Repository owner deleted a comment from github-actions Bot Jul 24, 2026
Repository owner deleted a comment from github-actions Bot Jul 24, 2026
@keshav0479

Copy link
Copy Markdown
Owner Author

The mobile app additionally has a share function in the hamburger menu. Similar to print, you should make it disabled for locked notes when the session is locked or the note is undecryptable, but enabled when the session is unlocked, and perform a gated loading before outputting the shared contents

done, share is disabled for locked notes while the session is locked or the note is undecryptable, and the shared text comes from a gated load

Screenshot 2026-07-24 203739

Repository owner deleted a comment from github-actions Bot Jul 25, 2026
@keshav0479

Copy link
Copy Markdown
Owner Author

An additional suggestion for change: I noticed the prompt which is shown when you enable encryption while the session is locked covers the entire screen. As it's not really necessary to mask anything in this scenario, and the embedded overlay covers the opening locked note scenario, I think you should make the prompt just a normal modal which only partially covers the screen, and has a translucent mask over the rest. This is more consistent with the behaviour on desktop, and also with modals in general on the mobile app.

done, it's a regular dialog with a translucent backdrop now, the full screen panel stays only for opening a locked note

Screenshot 2026-07-25 100605

@keshav0479

Copy link
Copy Markdown
Owner Author

Superseded by the upstream PR: laurent22#16069. The reviewed work is its first commit, with the newer changes as separate commits.

@keshav0479 keshav0479 closed this Jul 28, 2026
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.

2 participants