Repository navigation
v0.4.0 - #51
Merged
Merged
v0.4.0#51
Conversation
* fix(reader): don't let a conversation left behind redraw the reader ReaderViewModel.open() launches a load per conversation and never cancels it. When conversation A's bodies finished downloading after B had been opened, A's second show() replaced the reader's state with A: the user was looking at B and the screen switched back to A. Reachable today by tapping two notifications in a row; swiping between conversations makes it routine. The load still finishes: its bodies land in the cache, and cancelling it would throw away a download the user may come straight back to, which is the reason attachments run on the ViewModel scope too. It only stops drawing when its conversation is no longer the open one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BP7Zev5qvDRzKbcmecvRGW * feat(mail): swipe between conversations in the reader A horizontal swipe in the reader opens the conversation beside the open one in the list it came from: left for the next (older) one, right for the previous. The direction the content moves, as the calendar's pageOnSwipe reads it. Judged on release past 20% of the pane's width, the calendar's threshold, rather than followed under the finger. Following would mean composing the neighbour while the finger is down, and composing it expands and marks its newest message read on a glance nobody committed to. The new conversation slides a quarter-width in and fades from the side it was pushed towards; one tapped in the list still appears in place. The order is the list on screen. MailPane now collects the LazyPagingItems and hands them to MailScreen, so both read one copy of what Paging has loaded. The neighbour and the open row are read through threads[i], which is what keeps Paging loading ahead while the reader covers the list; reading the snapshot alone, a run of swipes stopped at the end of the first page. The message body is a WebView and takes the gesture before Compose sees it. Whether a wide table still has room cannot be asked: it scrolls inside #plmail-message-root, not the page, so canScrollHorizontally answers for a page that never moves sideways, and JavaScript is off. What Chromium reports is the remainder: a scroll the wrapper cannot take chains to the root scroller, and the unused delta comes back through overScrollBy even with over-scroll effects off. ReaderWebView counts it, so a table scrolls to its edge first and a swipe needs a further pull. If a WebView version does not report it, the body keeps today's behaviour and the rest of the reader still swipes. The reader is keyed on the conversation, so one opened by a swipe starts at its top, not at the previous one's scroll depth. TalkBack gets "Next conversation" / "Previous conversation" as custom actions, offered only where there is one; en and de strings. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BP7Zev5qvDRzKbcmecvRGW --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
The first phase of end-to-end encryption in the app (#9, the app's half of karatektus/pl_mail#28): getting the keys here, two ways, and keeping them safely. - :core:crypto (new, pure JVM): the browser's wrapping (`v1.<iv>.<ct>`, AES-256-GCM, purpose as associated data), its PBKDF2/HKDF derivation of the wrap key, opening user keys, the pairing bundle, and unlocking an address key with PGPainless 2.0.4 through its token. - :core:jmap: `#k=` from the pairing URI (never sent anywhere), `keys` in the redeem answer, the `urn:plmail:params:jmap:keys` capability and `keepsDecryptedMail`, and `Keyring/get`. - :core:datastore: KeyVault, the user keys sealed under a Keystore key of their own (`plmail.e2e.keys`), so phase 5 can put authentication on it without locking the app password. - :core:data: the bundle is opened while pairing and kept in memory until the connection is confirmed; EncryptionRepository reports where the device stands and opens the keyring with the plMail password or the encryption passphrase. A refused app password and signing out take the keys with them. - Settings: an Encryption screen (status, pairing hint, password or passphrase, address keys with fingerprints, remove), reached from the drawer. English and German. Tested against the server's KDF vector and fixtures written with Python's cryptography and the server's openpgp.js 6.3.2, and end to end on an Android 17 emulator with a minified release build against the test stack: pairing with keys from a real browser keyring, removing them, a wrong password, and unlocking with the right one. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
The second phase of end-to-end encryption in the app (#9): an encrypted message is decrypted on the device when it is opened, the way the web app reads it (assets/crypto/message.js). - :core:crypto: EncryptedMessages finds the ciphertext (PGP/MIME or inline), decrypts it with every address key the device can open, reports the signature inside, and parses the decrypted entity with mime4j: HTML, text, parts, and the subject protected behind `...` (RFC 9788). - :core:data: MessageDecryptor downloads the message's own blob (ciphertext, cached like any blob), judges the inner signature as the web app does (own key valid; pinned contact key valid, unpinned untrusted; another key key_changed; unknown key unknown_key; inner RFC 3156 unchecked), and sanitises the HTML with jsoup to the web app's DOMPurify rules, since no server has seen it. Decrypted content is held in memory only; a part opened in another app is written to the private cache and removed when the reader closes. Unlocked address keys are kept in memory for five minutes instead of being fetched and unlocked per message. - :core:jmap: the Email crypto properties in every list fetch, and ContactKey/get. - Database version 10: the five crypto facts on EmailEntity and ThreadEntity.isEncrypted (server facts only; a re-sync as every bump). - Reader: a banner with the web app's wording (EN, and the web app's own German): encrypted, the inner signature, a signed-only message's server verdict, and why a message did not open, with a retry. The protected subject replaces the placeholder. cid: images come from memory. - List: a lock on rows whose newest message is encrypted, also spoken. - Notifications: "Encrypted message" and nothing else, and no reply from the shade, which could only answer in the clear. - Release build: -dontwarn for slf4j's optional binding, which PGPainless now reaches; mime4j's duplicate META-INF/DEPENDENCIES excluded. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
The third phase of end-to-end encryption in the app (#9). An account can keep a readable copy of its encrypted mail on the server (switched on in the web app), so search, rules, parcels and dates work on it. The server has no keys to make that copy; a device that does hands it over, as the browser's decrypted_mail_controller.js does. - DecryptedMailKeeper asks DecryptedMail/pending for each such account, downloads each message's source, decrypts it, uploads the inner MIME message whole and hands it over with DecryptedMail/store. Inline PGP is wrapped exactly as the web app's inlineEntity wraps it. One run at a time, at most a hundred messages per run, and a round that stores nothing ends the run instead of asking for the same ids again. - It runs after the periodic sync, after a push, and when the app comes to the foreground (LiveUpdates), the moment the web app does it too. - :core:crypto: OpenedMessage.mime, the decrypted entity as the server is to receive it. :core:jmap: DecryptedMail/pending and /store. Checked on an Android 17 emulator with a minified release build against the pl_mail test stack: with keep-decrypted switched on for the account, six pending messages (PGP/MIME and inline) were handed over within ten seconds of the app coming to the foreground; the server then reports decryptedOnServer, replaced "..." with the protected subject, found the attachment, and finds the mail by search; the reader shows it from the server's copy with "Encrypted on arrival · stored readable on this server". Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
The fourth phase of end-to-end encryption in the app (#9). The server cannot build an encrypted message, so the app writes the whole message, encrypts it and imports it as a draft, which is then sent as before. - :core:crypto: OutgoingMessages builds the inner MIME entity as the web app's mime.js does (CRLF, base64 leaves, text and HTML, files, the protected Subject/From/To/Cc of RFC 9788), signs and encrypts it with PGPainless to every recipient and to the sender's own key (key IDs hidden when there is a Bcc), and wraps it in PGP/MIME with "..." as the outer subject. - :core:jmap: ContactKey/lookup, Email/import with plMail's fingerprints, and Email.sealedDraft. - :core:data: OutgoingEncryption asks the server about the recipients' keys and decides by the web app's rules (auto, on, off; partial encryption said out loud, blocked when a recipient asks for "always"). ComposeRepository saves an encrypted draft by upload and Email/import with $draft, and replaces it (old one to Trash) on every save, since the server cannot patch what it cannot read. It reopens such drafts decrypted, and decrypts the original of a reply or forward. - Composer: a lock in the app bar, a hint under the recipients in the web app's words, a lock on each recipient chip that has a key. An answer to encrypted mail is encrypted and quotes the decrypted original under its protected subject; a forward carries the decrypted files. Encrypted drafts are saved when the composer closes or sends, not while typing, and nothing is ever saved in the clear while the recipients' keys are still being asked about. A draft sealed by the web app, or one whose keys are not here, opens read-only with a reason. - Also fixes the composer opening on the previous message: on a phone its ViewModel belongs to the activity, so a second message in the same session (a reply included) showed the first one's content. It now resets once a message is saved, discarded or handed over. Not yet: signing without encrypting, inline PGP, S/MIME. Checked on an Android 17 emulator with a minified release build against the pl_mail test stack: a message to a contact with a key was imported as an encrypted draft (seal kind encrypted, both fingerprints), went out as multipart/encrypted with "Subject: ..." and no plaintext, and the web app decrypted it with "signed with your own key" and the protected subject. A reopened encrypted draft came back decrypted with its inner subject. A reply to an encrypted PGP/MIME message opened encrypted, with "Re: <protected subject>" and the decrypted quote, and went out encrypted. Mail to an address without a key is saved and sent in the clear as before. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
The fifth phase of end-to-end encryption in the app (#9): an optional setting, off by default, that asks for a fingerprint, face or the screen lock before encrypted mail opens, as the issue describes. - :core:datastore: KeystoreSecretCipher can gate its key on the person (setUserAuthenticationParameters, five minutes, strong biometric or device credential; unlocking the phone counts) and throws DeviceLockRequired instead of answering "unreadable". KeyVault keeps the keys under a second key, plmail.e2e.keys.protected, when the setting is on, re-seals them both ways on switching, and falls back to unprotected (and says so) when new keys arrive while it cannot seal. - :core:data: DeviceUnlock asks through whichever activity is on screen, once for any number of callers, and answers no when none is. The EncryptionRepository asks only for interactive callers: opening an encrypted message, answering one, sending encrypted. The reader shows "your keys are locked" with Unlock when declined; the composer stays open with the reason; the settings screen reports locked keys without asking; background decryption pauses until the app is used again. Typing recipients asks nothing: the composer takes the signing fingerprints from the keyring while the keys are locked. - app: the platform BiometricPrompt (API 30+, no new dependency), attached to MainActivity from onStart to onStop, and USE_BIOMETRIC. - Settings → Encryption: the switch, offered only with a screen lock. Checked on an Android 17 emulator with a PIN, minified release build against the pl_mail test stack: switching on asks for the PIN; five minutes later the list and a sync ask nothing, opening an encrypted message asks once, declining shows the locked banner and Unlock opens it, a second encrypted message then opens without asking, settings show "Locked until you confirm it is you" without asking, writing to a contact with a key asks nothing until Send, Send asks and the message goes out encrypted, a declined prompt at Send keeps the composer open and sends nothing, closing it declined saves nothing, and switching off asks again and leaves the keys open without it. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
#48) S/MIME in the app, reading half (#47). The person's certificates come from Keyring/get, opened with the user keys already on the device; S/MIME encrypted mail is decrypted here and its signature inside judged here, as the web app does in the browser. - :core:crypto: SmimeKeys opens a smimeCertificates entry (PKCS#8 under the user key, purpose plmail/smime-key). Smime decrypts CMS EnvelopedData and AuthEnvelopedData with Bouncy Castle's lightweight API (never a JCA provider, which on Android collides with the platform's own "BC"), including RSA-OAEP key transport, which the lightweight unwrapper reads as PKCS#1 v1.5 and fails. Ciphers: AES-CBC and AES-GCM, and the 3DES old Outlook sends. It checks a detached multipart/signed or an opaque SignedData inside, and returns the signed content without its wrapper (what DecryptedMail/store is handed, as from the web app). EncryptedMessages.open takes S/MIME keys and also opens opaque signed mail, which the server keeps no body for. - :core:data: EncryptionRepository opens and caches the certificates with the address keys (protection from phase 5 applies), reports them to the settings screen, and asks ContactKey/lookup for a sender's certificate. MessageDecryptor judges an S/MIME signature locally (own certificate valid; the sender's known certificate valid if verified, else untrusted; another one changed; one that does not name the sender, or broken, invalid), since the server's judging route is web-session only. The background keeper hands S/MIME over too. - Reader: "Encrypted with S/MIME", the web app's unreadable and unsupported hints, and opaque signed mail opened on the device. - Settings → Encryption: the person's S/MIME certificates, with issuer, expiry and state. - bcpkix 1.84 (the release PGPainless already brings), and a root lint.xml that ignores lint's TrustAllX509TrustManager in that one jar (EST code the app never reaches; R8 strips it). Checked on an Android 17 emulator, minified release build, against the pl_mail test stack: a .p12 imported in the web app shows under Settings → Encryption; a message signed by a contact and encrypted to the person (OpenSSL, AES-256) opens with its protected subject, HTML and attachment, "Encrypted with S/MIME" and an intact signature judged untrusted (the contact's certificate known from their signed mail, its CA not trusted); an opaquely signed message opens with its content and "intact, but nothing confirms who signed it". Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
S/MIME in the app, sending half (#47). When OpenPGP cannot reach every recipient but S/MIME can, the composer encrypts with S/MIME, as the web app does: every recipient has a certificate, the sender has one for the From address, and nobody is in Bcc. - :core:crypto: OutgoingMessages.sealSmime builds the same inner entity as for OpenPGP (protected headers inside, "..." outside), signs it as multipart/signed (SHA-256 with RSA, signer by issuer and serial, its certificate and chain carried, smimeCapabilities AES-256/192/128) and envelopes that with AES-256-CBC and RSA PKCS#1 v1.5 key transport to every recipient and the sender: smime.js's smimeEncryptEntity, which Outlook and Thunderbird read. - :core:data: the plan prefers OpenPGP, falls back to S/MIME, names the shorter list of the missing when neither works, and reports the scheme; RecipientKeys knows whether the From address has a certificate here (without opening protected keys). OutgoingEncryption seals with the certificate the server names for the sender and imports with every certificate fingerprint and that signer, as SealGuard checks. - Composer: a lock on the chip of a recipient with only a certificate ("plMail has an S/MIME certificate for this address…"), and the scheme carried on the draft it hands over. Checked with OpenSSL, standing for every other mail program: it opens what the app builds with the recipient's key and verifies the signature against the CA. On an Android 17 emulator, minified release build, against the pl_mail test stack: a new message to a contact with only a certificate shows the certificate lock and "will be end-to-end encrypted", is imported as kind encrypted, scheme smime, with two fingerprints and the person's certificate as signer, and goes out with no plaintext; OpenSSL decrypts it as the contact and verifies it. A reply to S/MIME encrypted mail opens with "Re: <protected subject>" and the decrypted quote and goes out the same way. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
# Conflicts: # app/proguard-rules.pro
…airing (#53) Right after pairing, the list could stay on "Fetching your mail…" with every row already on disk, until the app was restarted (#41). The cause is a missed Room invalidation. On a first launch the list's PagingSource is invalidated by the account and mailbox writes and loads again; when that second query ran just before the mediator committed its first page, and the source registered its observer just after the commit, nothing told it about the rows. Paging then held an empty page over a full table. The mediator's row count (onRowsHeld) said rows exist, so the screen chose "still fetching" over "empty", and nothing retried. The mediator now invalidates the list's PagingSource itself after every committed write, through an InvalidatingPagingSourceFactory, for the mailbox lists and for search. It costs one local query per page, which Room would have run anyway. Found on an Android 17 emulator with a minified release build against the pl_mail test stack: with the emulator's CPUs loaded, one pairing in three or four hung; the app's database then held all four feed entries with their threads and an exhausted cursor, and the paging log showed the second source load returning empty just before the mediator's commit, with no load after it. With this change, ten pairings under the same load all showed the inbox within a second. Closes #41 Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
The sidebar lists a tab while it holds mail, as the web does, but asked only the cache, and the cache holds a tab's conversations once the tab was opened or new mail arrived in it. A tab a filter on the web filled with existing mail, or one that rarely gets new mail, was therefore never listed, and so could never be opened (#52). After the tabs are read, the app now asks the server whether each enabled tab holds inbox mail: one Email/query per tab with the conditions its list pages with (in the Inbox, threadInboxTab), limit 1, collapsed by thread. The answer is kept on the tab (InboxTabEntity.hasMail) across re-reads, and the sidebar's "populated" is the union of the cache and that answer. On a push or a tab refresh where nothing changed, the questions ride along with InboxTab/changes, so that stays one request; the directory refresh and a refresh that re-read the tabs ask in a request of their own. Database version 11 for the new column (a drop and re-sync, as for every bump). Checked on an Android 17 emulator with a minified release build against the pl_mail test stack: a custom tab "Paket" with one existing conversation filed into it on the server, and nothing of it on the device, is listed in the sidebar right after pairing and opens with that conversation. Closes #52 Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…led (#56) A scanned or tapped pairing code was kept for the whole screen, and connect() preferred it to anything typed afterwards. When the code's address did not answer, correcting the address and pasting an app password still sent "Connect" to the scanned address. Editing the address or the app password now drops the invitation, so the form signs in with what it shows. Scanning again brings a fresh one. Closes #2 Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
… page (#55) The reader read Paging's snapshot in its own composition, so every page loaded, every sync and every row that changed its unread state recomposed it, walking the whole loaded list and building an ActionTarget per row to find the open conversation three times over. The open row and the rows beside it are now one derived value: the snapshot is still walked when it changes, without allocating, but the reader only recomposes when one of the three indices moves. Closes #34 Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Delta sync only asked Email/changes. When the server erased mail or merged conversations it reported the messages, never the conversation, so the thread and its list entries stayed for good: an erased conversation lingered, a merged one was listed twice. Thread/changes now travels in the same request, from a cursor a page records the way it records the Email one (set once, then moved by the sync). Its destroyed ids delete the thread and its entries in every list. A server that can no longer answer from either cursor discards both and re-pages. Closes #59 Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
A keyed LazyColumn keeps its first visible row in place when rows are inserted before it. At the top of an inbox that put arriving mail above the viewport, out of sight until the user happened to scroll up. The list now remembers whether it is at its top. When its position moves without a scroll, which only an insertion does, and it was at the top, it is put back there with requestScrollToItem before the next layout. Further down, and after a rotation restores a position, the anchoring stays as it was. Closes #60 Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…sume (#64) Live updates were started when the app came to the foreground. On a first launch that is the onboarding screen: nothing was stored, the catch-up found no accounts, the stream found no connection, and the job ended. A phone paired a moment later received nothing live until it had been in the background and back. LiveUpdates now follows the stored connection while the app is visible: the catch-up and the stream start the moment a connection is saved, restart for a different one, and stop when it is removed. Closes #43 Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
The reader expanded only the newest message. Unread messages before it stayed folded away above it, were easy to skip, and were never marked read, since a message is marked once it is shown expanded. Every unread message now starts expanded together with the newest, as the web app opens a thread, and the reader scrolls once to the first of them. The start is decided by the first rebuild that has messages and kept after that, so marking messages read while reading does not move it. What the user expanded or folded still survives the rebuild once bodies arrive. Closes #58 Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…r seen (#66) A list page is collapsed to one message per conversation, and opening a conversation only downloaded bodies for the messages already cached. A thread paged in from the list therefore opened as its newest message alone, under the last reply's subject, and nothing brought the others. Opening a conversation now asks Thread/get what it holds and fetches, in the one Email/get it already sent for missing bodies, the members the device lacks. The thread row is stored as the server describes it, so the list's subject and count are right afterwards too. A thread the device holds whole costs the Thread/get and nothing more. Closes #62 Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Merged
The app's numbering joins the server's from this release on (CONTRIBUTING.md): versionName 0.4.0, versionCode 400, up from 31, so it installs over v0.0.31. The bug template's version example follows. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
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.
Collects the work for the v0.4.0 milestone on
milestone/v0.4.0and brings it tomaintogether. The app release v0.4.0 ships alongside plMail v0.4.0 (karatektus/pl_mail#182). App versions jump from 0.0.31 straight to 0.4.0.A draft until the milestone is complete. Each piece is reviewed as its own pull request into
milestone/v0.4.0, and appears here once it is merged there.Closes #2, closes #9, closes #16, closes #34, closes #41, closes #47, closes #52, closes #43, closes #58, closes #59, closes #60, closes #62. Their pull requests went into the milestone, so the issues close when this one merges.
Pull requests in the milestone
DecryptedMail/*)ContactKey/lookup, MIME,Email/import, encrypted drafts, replies)Thread/changes)The last merge of
maininto the milestone was on 2026-10-22. It brought:Still open in the milestone
#2 (a corrected server address was ignored after a scan) is done by #56. The wrong address in the QR code was fixed on the server in plMail v0.3.2.
Depends on the server
End-to-end encryption needs plMail v0.4.0 (
urn:plmail:params:jmap:keys). Against an older server the app shows no encryption and otherwise works as before.In-Reply-To/References): fixed on the server.Before merging
🤖 Generated with Claude Code