Yahoo's guided app-password setup pointed appPasswordHelpUrl at the
generic account-security sign-in page, which assumes the user already
knows to hunt for "Create app password" once there. Point it instead at
Yahoo's own step-by-step "Generate and manage 3rd-party app passwords"
article, mirroring what #153 did for iCloud.
Yahoo does NOT gate app-password creation behind two-step verification
(verified against Yahoo's live help docs, which never list it as a
prerequisite), so — unlike Gmail and iCloud — it keeps twoFactorHelpUrl
null and shows no 2FA button. AppPasswordSetupScreen already renders the
help buttons generically from these provider fields, so no screen change
is needed; the existing PR #152 ordering (2FA link before the
app-password link) is preserved for the providers that have both.
Add a MailProviderTest assertion pinning Yahoo's new app-password URL
(mirroring the iCloud test) and extend the onboarding
yahooSetup_hasNoTwoFactorHelpLink coverage note for #155.
Closes#155
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the small inline "Report sent. Thank you!" text with an AlertDialog
carrying the fuller thank-you/no-guarantee message, and gate the screen's
auto-navigate-on-delete LaunchedEffect so it no longer fires while a submit
is in flight or has just succeeded — the dialog's acknowledgement is what
calls onDone() for that path instead. This closes the race where
ReportUploadWorker deletes the report row (and thus flips state.exists to
false) moments after SubmitUiState.SUCCEEDED, which could previously
navigate the user away before the confirmation was ever visible. Discard
and the other non-success paths (FAILED/UNAVAILABLE) are unaffected and
still auto-navigate immediately.
Closes#161
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
iCloud's guided setup screen previously linked only a generic Apple ID
sign-in page and had no two-factor help link, unlike Gmail. Apple also
requires two-factor authentication before it will issue an
app-specific password, so:
- MailProvider.ICLOUD.appPasswordHelpUrl now points at Apple's actual
app-specific-password instructions (support.apple.com/en-us/102654)
instead of the generic appleid.apple.com landing page.
- MailProvider.ICLOUD.twoFactorHelpUrl now points at Apple's dedicated
two-factor-authentication article (support.apple.com/en-us/102660),
so the existing generic 2FA-help button in AppPasswordSetupScreen
picks it up automatically, positioned the same as Gmail's (#152).
- The 2FA button now reads "How to turn on Two-Factor Authentication"
for iCloud instead of Google's "2-Step Verification" wording, via a
new app_password_2fa_help_icloud string.
Closes#153
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 18:34:14 -05:00
Jason RossClaude Opus 4.8github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
* fix(onboarding): request notification permission after welcome screen renders
The POST_NOTIFICATIONS request fired from a MainActivity-root
NotificationPermissionEffect whose LaunchedEffect(Unit) ran on the very
first composition, so the system dialog could pop the instant the icon
was tapped — overlapping cold start/splash before any onboarding context
was on screen.
Move the effect into OnboardingWelcomeScreen so it fires once that screen
(the onboarding start destination) is composed and visible, with the
welcome content behind the dialog. Already-onboarded users launch
straight into the mailbox and never compose the welcome screen, so they
are unaffected; the API 33+ gate and the already-granted no-op are
preserved unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* test(onboarding): grant POST_NOTIFICATIONS in onboarding E2E to fix API 33+ flow
Moving the notification-permission request into OnboardingWelcomeScreen
(#151) means the system POST_NOTIFICATIONS dialog now pops when that
screen composes. On API 33+ (where it became a runtime permission) the
dialog backgrounded the activity mid-flow, so OnboardingFlowTest failed
with "No compose hierarchies found" on API 33/34/35/36/37 while API
29–32 stayed green.
Pre-grant the permission via a GrantPermissionRule so the dialog never
appears during the flow, guarded for API 33+ (the permission does not
exist below TIRAMISU, so grant nothing there to avoid erroring on older
devices). Adds the androidx.test:rules dependency that provides the rule.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Closes the #36 test-coverage gap left after the rich-text editor shipped:
- RichTextEditorTest.kt (new JVM unit test, 20 cases): exercises the
Compose-editor glue in RichTextEditor.kt that had no direct coverage -
applyStyle/applyBlock/applyLink, the AnnotatedString.toRichContent() <->
RichTextContent.toAnnotatedString() round trip across every span/link/
alignment/image/baseStyle channel, and the isStyled/hasBlock predicate
FormattingToolbar uses to light up its buttons. All plain TextFieldValue/
AnnotatedString/Color types, so it runs on the JVM with no emulator.
applyBlock/applyLink go from private to internal so the test can reach
them directly, mirroring applyStyle's existing internal visibility.
- ComposeScreenTest.kt (androidTest, following this file's existing
createAndroidComposeRule + fake-repository harness): one case taps the
bullet-list toolbar button and asserts the sent message carries the
<ul><li> HTML (block markers apply to the caret's line, so no fragile
on-device range selection is needed); another asserts every toolbar
button's click-action label matches its string resource, verifying the
accessibility claim (the labels are onClickLabel, not contentDescription).
Headings remain deliberately out of scope per the ticket - no model/
toolbar changes here.
Closes#36
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2-Step Verification is a prerequisite for Gmail's app-passwords page, so
render the twoFactorHelpUrl button first when present, then the
appPasswordHelpUrl button. Previously the prerequisite link rendered
second, so a user without 2FA enabled would hit a dead end on the first
button before noticing the second. No visible change for Yahoo/iCloud,
which have no twoFactorHelpUrl.
Closes#152
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-07-02 22:42:37 +00:00
Jason RossClaude Opus 4.8github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
selectFolder() kicked off its background syncFolder() fetch fire-and-forget
with no loading flag, so opening a per-account folder with no cached
messages yet flashed "No messages to display" for the whole IMAP fetch
instead of a spinner. Adds isSyncingFolder, a StateFlow set for the
duration of that sync (mirroring isRefreshing) and cleared via try/finally
regardless of outcome. A private latestFolderSelection token guards the
clear so a stale sync from a folder no longer selected can't hide the
spinner for whichever folder is actually selected now.
MailboxScreen's non-paged empty branch now holds NoMessagesState back
while isSyncingFolder is true, showing a CircularProgressIndicator
instead — mirroring how the unified-inbox paged branch already gates on
loadState.refresh.
Closes#149
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Reorders SettingsScreen's top-level (non-Advanced) sections per #158:
Accounts, Message downloading, Contacts, Appearance, Settings Backup,
Notifications, Storage on this device, then a header-less trailing
Report a Problem row (mirrors AccountSettingsScreen's headerless
"Remove account" row now that Diagnostics is down to one item).
- Move "Message downloading" up to directly follow Accounts.
- Add a two-line descriptive subtext under the Appearance header
("Match device theme" / "Material You theming (Android 12+)"); no
new toggle, since LibreMailTheme already always follows the system
light/dark setting via isSystemInDarkTheme().
- Rename settings_backup "Backup" -> "Settings Backup" and
settings_new_mail "New-mail notifications" -> "New mail
notifications" (drop hyphen).
- Drop the now-single-item settings_diagnostics header string; keep
Contacts positioned directly before Appearance (its prior relative
spot), per the ticket's suggested safest default for the
unresolved placement question.
Advanced's internal order is untouched (out of scope; tracked by
#162).
Closes#158
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
The TextButton trigger and its DropdownMenu in AccountSwitcher were
siblings in the drawer's outer Column, so the dropdown's Popup anchored
to the whole Column instead of the button. Wrap both in a shared Box,
the standard Compose pattern, so the menu opens directly below the
account switcher regardless of drawer scroll position or folder count.
Closes#147
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Extract the AES-256-GCM Android Keystore plumbing that `KeystoreCrypto` and
`DatabaseKeyCipher` copy-pasted (~60 lines) into a shared alias-parameterized
base, `AesGcmKeystoreCipher`: the encrypt/decrypt bodies, existing-key lookup /
get-or-create under a lock, key deletion, and the 5 identical GCM constants now
live in ONE place. Each cipher keeps only its delta — the `KeyGenParameterSpec`
(via `keySpecBuilder()`) and, for the auth-bound key, the invalidation handling.
Preserve — deliberately — the two ciphers' different missing-key-on-decrypt
behavior via a `generateKeyOnDecrypt` policy parameter, documented on the base:
- master key (`KeystoreCrypto`, true): auto-generates on a missing alias, correct
for a first-run key with nothing sealed yet.
- auth-bound cache key (`DatabaseKeyCipher`, false): fails fast, because a missing
auth-bound key means it was INVALIDATED and silently regenerating it would
re-arm the lock against a cache that can no longer be decrypted.
Also map the opaque `AEADBadTagException` (thrown when the master path generates a
fresh key then can't decrypt old data) to a clear `GeneralSecurityException`,
while leaving `KeyPermanentlyInvalidatedException` to propagate unwrapped.
Unify the accepted-authenticator policy behind one source of truth,
`AuthenticatorPolicy.ACCEPTED`, mapped into each API's vocabulary
(`AppLockManager.AUTHENTICATORS` for BiometricManager / BiometricPrompt,
`DatabaseKeyCipher.keySpec` for KeyProperties / KeyGenParameterSpec) so the two
can no longer drift — a drift that yields a prompt that succeeds but a key that
throws `UserNotAuthenticatedException` at use.
Add JVM tests for the shared base (both `generateKeyOnDecrypt` modes + the AES-GCM
error mapping) and for the authenticator mapping. #100's seal-exchange and
policy-table safety net stays green.
Closes#102
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Close the test-coverage gap on the app-lock security core (#100): the
branching that decides when to WIPE user data or drop the lock, which
shipped largely untested.
- AppLockViewModelTest: pin the onAuthenticated unlock/arm classification
(OK / UNRECOVERABLE / RETRY) and the onForeground LockAction dispatch --
DISABLE_APP_LOCK persists the setting, CLEAR_* set the pending flag and
drop the gate BEFORE the awaited re-sync enqueue and process restart,
and CLEAR_AND_REQUIRE_AUTH clears + restarts but keeps app-lock on.
- KeyInvalidationPolicyTest: make the exhaustive 16-row decision table a
test, with a completeness guard so no row can be dropped. The common
(appLock on, encrypt off, secure, valid) -> REQUIRE_AUTH row is now
pinned, so a mutation to PROCEED (a silent lock bypass) fails.
- DatabaseKeyStoreTest: new JVM tests for the dual-seal exchange
(sealWithAuth dropping SEALED_MASTER, sealWithMaster, resetSealedPassphrase,
unlockWithAuth, clear-pending) pinning the "never both seals at once" and
"not recoverable without auth" invariants.
- SettingsViewModelTest: setAppLock reject / reseal / disable branches.
To make the device-only DatabaseKeyStore crypto JVM-testable, add a
minimal @VisibleForTesting DataStore seam (mirroring AppLockViewModel's
injectable dispatcher); production still uses the real per-app DataStore.
No crypto plumbing is refactored.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Inline images in rich HTML emails (embedded via Content-ID and
<img src="cid:...">, e.g. USPS Informed Delivery digests) were listed
under Attachments with a download button and never rendered in the body.
Two bugs combined; both are fixed here.
1. Misclassification: ImapClient classified any part with a filename as
an attachment, sweeping inline images (which carry a filename AND a
Content-ID under Content-Disposition: inline) into the list. A part is
now a downloadable attachment only when its disposition is attachment,
or it has a filename but no Content-ID; an inline image is collected
separately and excluded from the displayed list (AttachmentDao filters
contentId IS NULL). The Content-ID is read via MimePart.getContentID()
so it resolves from IMAP BODYSTRUCTURE rather than a per-part header
fetch that Angus leaves unpopulated.
2. No rendering path: HtmlBody's WebViewClient now overrides
shouldInterceptRequest to resolve cid:<id> to the matching part's
bytes (backing the CSP's existing cid: allowance). Content-ID is
threaded end-to-end through AttachmentPart, Attachment,
AttachmentEntity, and MailRepository.inlineImages(); ReaderViewModel
surfaces the cid->bytes map to the WebView.
Schema: adds attachments.contentId (v16 -> v17, MIGRATION_16_17).
Tests: MIME-part classification (inline+cid excluded, real/disposition/
filename-only kept), a GreenMail multipart/related round-trip, the
cid->bytes resolver, repository inlineImages(), the DAO display filter,
and the v16->v17 migration.
Closes#133
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The key-invalidation recovery restart was unreliable in two ways, both in
AppLockViewModel:
1. Same-process self-restart race: restartProcess() did
context.startActivity(...) immediately followed by Runtime.exit(0) in the
same process, so ActivityManager could schedule the relaunch into the
process being killed and drop it — the app just closed, recovering only on
the next manual launch. Fixed with a ProcessPhoenix-style separate-process
trampoline (RestartActivity in a distinct ":restart" process, driven by
ProcessRestarter): it kills the original process by PID and only then
relaunches, so the relaunch is issued from a process that survives the kill.
No new dependency; LibreMailApplication early-returns in the ":restart"
process so it runs no normal startup work.
2. Lost syncNow() enqueue: clearCacheAndRestart() enqueued the post-wipe
re-sync fire-and-forget, but WorkManager persists the WorkSpec
asynchronously on its serial task executor, so exiting raced that insert and
could drop the re-sync (now user-visible after #118: an empty mailbox until
the next periodic sync). syncNow() now returns its enqueue Operation, and
clearCacheAndRestart awaits it (bounded by a 5s timeout) before restarting,
so the WorkSpec is durably persisted first.
CLEAR_PENDING recovery-flag semantics are preserved; the cache wipe still
happens at cold start in DatabaseModule (unchanged).
Tests: JVM unit tests assert the enqueue Operation is awaited before the
restart is triggered (order) and that a timed-out enqueue still restarts;
SyncSchedulerTest pins syncNow() returning the enqueue Operation. The
separate-process kill/relaunch is device-only and noted for on-device
wipe+resync verification.
Closes#99
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
DatabaseModule.provideDatabase ran the whole startup sequence with
runBlocking while Hilt constructed the singleton database — a DataStore
read, a Keystore op, a possible SQLCipher re-key conversion, and (since
#111) the cross-database AccountDataMigrator — synchronously on whichever
thread first injected it, which can be the main thread (jank / ANR).
Move that work behind DatabaseProvisioner.prepareCache(): a memoized,
mutex-guarded suspend that runs the same sequence, in the same order, on
the IO dispatcher. Both databases' Room builders now open through a
DeferredOpenHelperFactory whose delegate — and therefore the gate — is
materialised only when Room first OPENS the database, on its background
query executor, never at inject time. AccountDatabase's open gates on the
same prepareCache(), preserving the #111 migrate-before-open ordering that
the old construction-time dependency on LibreMailDatabase enforced.
Behaviour, ordering, and crash-safety are unchanged — only where and when
the work runs moved off the (possibly main) inject thread.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A message with several attachments used to render every AttachmentRow
stacked vertically, pushing the message body arbitrarily far down. Now
only the first attachment shows by default; when there is more than one,
the extras collapse behind a "See x more attachments" control that
expands and collapses with an animated, rotating chevron. A single
attachment renders exactly as before (no accordion).
The count uses a plurals resource (quantity one/other) so it reads
"See 1 more attachment" / "See 2 more attachments" correctly. The toggle
is one clickable Role.Button whose label and chevron contentDescription
expose the expanded state to screen readers. Download/open behavior of
each row is unchanged.
Refs #134
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Make BackupPolicy.EXCLUDED_DATABASE_PATHS the true single source of truth
by deriving it from DatabaseFiles.NAME and DatabaseFiles.ACCOUNTS_NAME plus
their SQLite sidecars via a new DatabaseFiles.fileNames() helper, instead of
a hand-maintained list. This adds libremail-accounts.db (accounts + encrypted
credentials, split into their own DB by #118/#111) to the never-back-up set,
matching the field's stated intent, so a newly added database can never
silently fall out of the exclusions again.
Also fix DatabaseFiles.clear to wipe the cache DB via
context.deleteDatabase(NAME), which additionally removes the -mj*
master-journal temp files the hand-rolled suffix list missed. It still wipes
ONLY the cache DB (NAME) and never the accounts DB (ACCOUNTS_NAME), preserving
the sign-in-survives-cache-wipe separation from #111.
Update the backup XML comments (data_extraction_rules.xml, backup_rules.xml)
to note libremail-accounts.db is also kept off-device by the strict include-
allowlist, and extend the tests to assert the accounts DB is covered by the
exclusion SoT and that the derivation stays in lockstep with the XML resources.
There is no active backup leak today: the XML is a strict include-allowlist,
so the accounts DB was already excluded by omission. This closes the SoT drift
#118 introduced and the -mj* gap, so the security posture no longer depends on
the allowlist staying strict by luck.
Closes#103
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>