Author SHA1 Message Date
JMR-devandClaude Opus 4.8 aee5e57ba8 fix(onboarding): enforce GPL license gate for upgrade users
AppViewModel.startDestination chose onboarding-vs-mailbox purely by
account count, so a user upgrading from a pre-#172 install (accounts
present, licenseAccepted=false) went straight to the mailbox and never
saw the license screen — the license is only the onboarding graph's
start destination. Route to ONBOARDING whenever the license is
unaccepted, even when accounts exist; only an accepted user with an
account lands on the mailbox.

Once such a user accepts, send them straight to their inbox rather than
ONBOARDING_WELCOME ("add your first account"), which would strand a user
who already has accounts. AppViewModel now also exposes hasAccounts for
that post-accept routing decision.

Also guard the pendingCompose mailto/share deep-link with the same
start != ONBOARDING check pendingOpenMessageId already uses, so a
deep-link can't jump past the license gate either.

From the post-batch security review, refs #172.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 03:18:36 -05:00
Jason Ross 947edab341 Merge pull request #201 from JMR-dev/feat-77-inline-images
feat(compose): inline images
2026-07-03 01:46:59 -05:00
JMR-devandClaude Opus 4.8 96b2da1753 feat(compose): inline images end to end (picker to SMTP/Graph)
Wire inline images through the whole send pipeline.

Compose UI: an image-picker (image/*, persistable URI grant, mirroring the
attachment picker) behind a new toolbar button appended at the END of the
toolbar — after the block/link buttons and the font/size/align controls — so it
never shifts the bullet button the compose E2E taps without scrolling. Picking
an image adds an inline OutgoingAttachment and hands the editor a
PendingInlineImage, which RichTextEditing.insertImage drops as a [image: name]
token + RichImage(contentId) at the caret. Deleting the token drops the image:
onBodyChange reconciles inline attachments against the body's surviving cid:
references. Inline images are tracked in ComposeUiState alongside regular
attachments but kept out of the attachment-chip row.

Domain/persistence: OutgoingAttachment gains contentId/isInline; the shared
draft/outbox attachment JSON carries them (drafts need no migration — an older
draft reads back as a plain attachment). The outbox stages files by index as
before but now also stores per-file {contentId,isInline} metadata in a new
OutboxEntity.attachments column (Room 17 -> 18, MIGRATION_17_18, DEFAULT '' per
the bccAddresses precedent so fresh-install == migrated; 18.json committed). The
send worker pairs each staged file with its metadata by index (with a positional
fallback for messages queued before the column existed).

SMTP (SmtpSender): inline images wrap the body in a multipart/related, each with
a Content-ID matching the HTML's cid: and inline disposition; regular
attachments keep today's multipart/mixed shape.
Graph (GraphSender): inline fileAttachments carry isInline + contentId.

Tests: GreenMail asserts multipart/related with a Content-ID matching the cid;
GraphSenderTest asserts the inline payload; a mapper test proves an inline
image's cid<->file pairing round-trips a draft save/reopen; RichTextEditing
tests cover insertImage (token/RichImage placement + offset shift + html
round-trip); MigrationTest gains migrate17To18. Reader-side cid: rendering stays
out of scope (follow-up).

Closes #77

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 01:33:48 -05:00
Jason Ross 0a2ee66f6f Merge pull request #199 from JMR-dev/feat-177-draft-autosave
feat(compose): debounced periodic draft autosave
2026-07-03 00:58:34 -05:00
Jason Ross ad8fd1e24f Merge main into feat-177-draft-autosave 2026-07-03 00:45:19 -05:00
Jason Ross b5655de214 Merge pull request #200 from JMR-dev/feat-72-font-family-picker
feat(compose): font family picker with bundled open-source fonts
2026-07-03 00:44:50 -05:00
JMR-devandClaude Opus 4.8 071034de65 feat(compose): add a font family picker with bundled open-source fonts
Bundle four SIL OFL 1.1 fonts in res/font (no build-time downloads, F-Droid
safe): Inter, Lora, and JetBrains Mono as weight-variable TTFs plus a static
Merriweather Regular cut (its variable font is 4.4 MB) — ~1.5 MB total. Each
family's OFL license text is committed under THIRD_PARTY_LICENSES/, and *.ttf/
*.otf are marked binary in .gitattributes so the bytes commit intact.

Add FontRegistry: display name <-> email-safe CSS stack <-> Compose FontFamily,
with three generic Sans/Serif/Monospace entries that need no bundled file. Each
bundled stack names the family first then falls back to a generic (e.g.
'Lora', Georgia, serif), so a recipient whose client lacks the face still gets
a sensible one. resolveFontFamily maps a stored CSS stack back to a FontFamily
for in-editor rendering, and is now passed into RichTextBodyField from
ComposeScreen so styled runs actually render in their font.

Add FontPicker (ui/compose/format): a toolbar dropdown applying
RichStyle.FontFamily(css) via the generalized applyStyle/clearStyle path, with a
leading "Default" entry that clears it; each menu entry previews itself in its
own face. Appended at the END of the toolbar (with the size/alignment controls),
after the block and link buttons — per the #73/#76 lesson, nothing may shift the
bullet/numbered/quote buttons that the compose E2E taps without scrolling.

Tests: FontRegistryTest (JVM) proves every CSS stack survives an html
round-trip, the resolver maps known/unknown stacks, and bundled stacks end in a
generic fallback; a compile-only FontPickerTest drives the picker in isolation
(default label, current-font label, menu lists every font, picking reports the
css / Default clears).

Closes #72

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 00:31:09 -05:00
JMR-devandClaude Opus 4.8 bbc0715666 feat(compose): debounced periodic draft autosave
Adds a debounced periodic draft save alongside the existing exit-time
save, so an in-progress compose survives a background-kill without going
through the back gesture. Observes the persisted body/recipient/subject/
attachment fields, coalescing rapid keystrokes into one write after a
~1.5s idle window (viewModelScope), and flushes immediately on ON_STOP
from ComposeScreen so the last keystrokes within the window aren't lost.

Prerequisite bug fix: draftId was a nullable val, so
saveOrDeleteDraft()'s `id = draftId ?: UUID.randomUUID()` minted a fresh
id on every call. Harmless when it ran only once at exit, but periodic
autosave would insert a new duplicate draft row per tick. The persist id
is now generated once (persistedDraftId) and reused for every save this
session; a draftPersisted flag drives delete-on-empty and delete-on-send
so an autosaved new draft is never orphaned.

onExit()'s save-or-delete-on-back behavior and the "don't save mid-send"
guard are unchanged; autosave mirrors the same guard (plus a navigated
guard so a post-send tick can't re-create a sent message's draft).

Closes #177

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 00:22:42 -05:00
Jason Ross e3d54aadd2 Merge pull request #198 from JMR-dev/feat-76-paragraph-alignment
feat(compose): paragraph alignment
2026-07-03 00:08:40 -05:00
Jason Ross 6fff3fcf02 Merge main into feat-76-paragraph-alignment 2026-07-02 23:55:54 -05:00
Jason Ross 8e44d1000c Merge pull request #197 from JMR-dev/feat-78-remember-font-size
feat(compose): remember last-used font and size
2026-07-02 23:55:23 -05:00
JMR-devandClaude Opus 4.8 df5c1aa2f9 feat(compose): add paragraph alignment to the formatting toolbar
Add setAlignment(content, start, end, align) and alignmentAt(...) ops to
RichTextEditing with paragraph-range bookkeeping (mirroring toggleBlock).
setAlignment marks every line the selection touches, leaves untouched
paragraphs alone, and stores START as "no alignment" (dropping the range) so
an otherwise-plain paragraph stays plaintext-only; CENTER/END become explicit
ranges. It emits the same canonical form RichTextHtml.fromHtml returns — one
merged range per run of adjacent same-aligned lines, and blank paragraphs
anchor no range (the HTML model can't pin text-align to an empty <p>) — so the
model, its HTML, and the editor's ParagraphStyle rendering never drift.
alignmentAt returns the shared alignment (START default for plain paragraphs,
null when mixed), driving a three-state control directly.

Add ParagraphAlignmentControl (start/center/end glyph buttons) and append it —
plus the existing FontSizePicker — at the END of the toolbar, after the block
and link buttons. Per the #73 lesson, nothing may shift the bullet/numbered/
quote buttons rightward or the compose E2E's no-scroll performClick on "•" (and
siblings) misses.

Editor renders alignment via the existing ParagraphStyle(textAlign) path
(applyAlignment routes through it, preserving the selection since alignment
never changes the text).

Tests: setAlignment/alignmentAt covered thoroughly at the JVM layer (caret,
multi-paragraph merge, mid-block split, untouched paragraphs, blank lines,
empty document, mixed selections) plus a serialize->parse round-trip fixpoint
on setAlignment output; applyAlignment covered in RichTextEditorTest; a
compile-only androidTest drives the control in isolation.

Closes #76

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 23:51:50 -05:00
Jason Ross 2df6b0f82a Merge main into feat-78-remember-font-size 2026-07-02 23:41:19 -05:00
Jason Ross 79c3f57834 Merge pull request #194 from JMR-dev/feat-160-app-password-disclaimer
feat(accountsetup): warn against using account password for app password
2026-07-02 23:40:50 -05:00
JMR-devandClaude Opus 4.8 5e5116cbca feat(compose): remember last-used font and size
Persists the font family/size from a sent formatted message to the
settings DataStore (SettingsRepository.setLastFont), taking the
message-wide base style if set, else the last FontFamily/FontSize
span. Brand-new compositions (draftId == null) seed a RichBaseStyle
from the remembered preference so the whole message defaults to it;
resumed drafts and replies/forwards are untouched, and messages with
no remembered font stay plaintext-only.

Closes #78

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 23:32:58 -05:00
Jason Ross d9cfff80ec Merge main into feat-160-app-password-disclaimer 2026-07-02 23:28:26 -05:00
Jason Ross 1663a2781b Merge pull request #195 from JMR-dev/feat-73-font-size-control
feat(compose): font size control
2026-07-02 23:27:55 -05:00
JMR-devandClaude Opus 4.8 9c6a969c17 fix(compose): keep the bullet button tappable by appending the font-size control last
The font-size dropdown was inserted before the block-marker buttons, and its
wide "Default"/"N pt" anchor pushed the "•" bullet button past the right edge
of the horizontally-scrolling toolbar on the Pixel 2 E2E device (411dp wide,
minus the compose column's 16dp padding = 379dp usable). ComposeScreenTest's
formattingToolbar_bulletButtonMarksTheLineAndSendsItAsHtml taps the bullet
without scrolling first, so performClick targeted a center that was clipped
off-screen and the tap silently missed — the line was never marked, failing
all 8 instrumented legs deterministically (expected "• Buy milk", got "Buy milk").

The block-toggle logic was never touched; this was pure toolbar overflow. Move
FontSizePicker to the end of the toolbar (after the link button) so every
pre-existing glyph button keeps the exact position it has on main and the
bullet stays within the initial viewport. Add a comment recording the ordering
constraint for future toolbar tickets.

Also add a JVM unit test (RichTextEditorTest) that drives the same bullet-tap
flow through applyBlock + RichTextHtml.toHtml, pinning "• Buy milk" and
<ul><li>Buy milk</li></ul> so a regression in that block/HTML path is caught
by testDebugUnitTest without an emulator.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 23:13:59 -05:00
Jason Ross b7c7415fef Merge main into feat-160-app-password-disclaimer 2026-07-02 23:09:17 -05:00
Jason Ross 1634c57837 Merge main into feat-73-font-size-control 2026-07-02 23:09:16 -05:00
Jason Ross ca437671e3 Merge pull request #196 from JMR-dev/feat-172-gpl-license-onboarding
feat(onboarding): require GPL-3.0 license agreement as the first screen
2026-07-02 23:08:47 -05:00
JMR-devandClaude Opus 4.8 bae25b20f3 feat(onboarding): require GPL-3.0 license agreement as the first screen
Inserts a new LicenseScreen ahead of OnboardingWelcomeScreen as the
onboarding graph's start destination: the user must scroll the full
GPL-3.0 text and tap Agree before reaching anything else, or Decline
to exit the app outright. Acceptance is persisted
(SettingsRepository.licenseAccepted) so a user who agrees but exits
before adding an account isn't asked again, and the
NotificationPermissionEffect() request (#151) stays scoped to
OnboardingWelcomeScreen so it never fires on the license screen.

Closes #172

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 22:56:34 -05:00
JMR-devandClaude Opus 4.8 23fa389c0a feat(compose): add font size control to the formatting toolbar
Add a preset-size dropdown (10/12/14/18/24pt, plus Default to clear) to the
compose FormattingToolbar via a new FontSizePicker composable, applying
RichStyle.FontSize over the selection through the existing generalized
applyStyle/clearStyle toggle path (no font-size-specific branching needed).
The anchor button shows the selection's current size, or "Default" when
unset/mixed. The rich-text foundation already provided RichStyle.FontSize,
its pt/px-tolerant HTML round-trip, and pt->sp mapping for in-editor
rendering; this ticket wires up the missing UI control.

Closes #73

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 22:38:22 -05:00
JMR-devandClaude Opus 4.8 c3cd567da2 feat(accountsetup): warn against using account password for app password
New users unfamiliar with app passwords commonly try their regular
account password first and get a confusing auth failure. Add a
disclaimer as supporting text directly under the "App password" field
on AppPasswordSetupScreen (shared by every preset provider), instead
of leaving the warning only in the intro InfoCards above.

Closes #160

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 22:29:53 -05:00
Jason Ross 4872d24981 Merge pull request #190 from JMR-dev/feat-156-154-aol-provider
feat(accountsetup): add AOL provider with app-password help and IMAP/SMTP presets
2026-07-02 21:37:38 -05:00
Jason Ross af22467817 Merge main into feat-156-154-aol-provider 2026-07-02 21:26:33 -05:00
Jason Ross 04b856e10c Merge pull request #189 from JMR-dev/feat-74-75-compose-color-highlight
feat(compose): add font color and text highlight to the formatting toolbar
2026-07-02 21:26:02 -05:00
Jason Ross fd80bded67 Merge main into feat-156-154-aol-provider 2026-07-02 21:15:15 -05:00
Jason Ross 2ce2cb39d2 Merge main into feat-74-75-compose-color-highlight 2026-07-02 21:15:14 -05:00
Jason Ross 5826317182 Merge pull request #191 from JMR-dev/test-53-sync-concurrency
test(sync): interleaving tests for the ungated sync↔backfill and sync↔prune pairs
2026-07-02 21:14:46 -05:00
Jason Ross 4f588b8fd1 Merge main into feat-74-75-compose-color-highlight 2026-07-02 21:03:12 -05:00
Jason Ross 4dd2c20053 Merge main into feat-156-154-aol-provider 2026-07-02 21:03:11 -05:00
Jason Ross cf274781bd Merge main into test-53-sync-concurrency 2026-07-02 21:03:10 -05:00
Jason Ross 33c826b7ec Merge pull request #188 from JMR-dev/perf-186-message-open
perf(reader): render message body once, move openMessage IO off-main, drop wasted work
2026-07-02 21:02:42 -05:00
JMR-devandClaude Opus 4.8 b4a450a8b8 test(sync): interleaving tests for the ungated sync↔backfill and sync↔prune pairs
MailMaintenanceGate serializes only the backfill↔prune pair. The other two
pairs — sync↔backfill and sync↔prune — are deliberately ungated (foreground
sync uses its own syncMutex to stay UI-responsive) and rely on a disjoint-by-UID
argument for safety, with no test covering it (issue #53).

Add MailSyncConcurrencyTest: a real MailSyncer and a real MailBackfiller/
MailPruner wired to one shared in-memory message store, with CompletableDeferred
gates (mirroring MailMaintenanceGateTest) that park one actor mid-critical-
section while the other's whole critical section runs. Each interleaving is
driven to the boundary (window edge / count floor) where an overlap would
surface as a lost, duplicated, or wrongly-deleted row:

- sync's windowed reconcile runs while a backfill is parked mid-paging just
  below the window (tightest edge: lowestSyncedUid == minWindowUid);
- a backfill's below-window page lands after a concurrent full sync of the
  window (stale-boundary ordering);
- a count-retention prune runs while a foreground sync is parked in its fetch;
- a full foreground sync runs while a prune is parked inside its critical
  section, before it touches the message table.

All four pass: the disjointness invariant holds unlocked. No production code
changed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 21:00:23 -05:00
JMR-devandClaude Opus 4.8 ef43dc9a79 feat(accountsetup): add AOL provider with app-password help and IMAP/SMTP presets
Adds MailProvider.AOL as a guided app-password provider (after iCloud, before
Other), closing the coupled pair of #156 (app-password help content) and #154
(IMAP/SMTP presets):

- appPasswordHelpUrl points at AOL's "Create and manage 3rd-party app
  passwords" article. No twoFactorHelpUrl: verified against both AOL's
  app-password article and its separate two-step-verification article that
  neither treats 2FA as a prerequisite for generating an app password (AOL
  mirrors Yahoo here, not Gmail/iCloud).
- IMAP imap.aol.com:993 (SSL/TLS); SMTP smtp.aol.com:465 (SSL/TLS) — AOL's
  official docs and the Thunderbird ISPDB autoconfig only document implicit
  TLS on 465 for submission, with no STARTTLS/587 alternative, so this
  follows Yahoo's rationale rather than Gmail/iCloud's STARTTLS preset.

AppPasswordSetupScreen's two exhaustive `when` blocks (providerIntro,
twoFactorHelpLabel) gain an AOL branch; AccountPickerScreen already lists
providers generically via MailProvider.entries. Test coverage mirrors the
Yahoo/iCloud assertions: presets, help URLs, no twoFactorHelpUrl, fromKey,
forImapHost, and brandFor resolving a manually-configured imap.aol.com
account to the AOL brand.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 20:59:17 -05:00
JMR-devandClaude Opus 4.8 5120d65793 feat(compose): add font color and text highlight to the formatting toolbar
Wire the previously-unused ColorSwatchRow into the compose FormattingToolbar
with two new controls: a font-color button applying RichStyle.FontColor and a
highlight button applying RichStyle.Highlight over the selection. Each opens a
ColorPickerDialog built on the shared ColorSwatchRow (~8 font colors; yellow /
green / cyan / pink highlighter markers), with a "no color"/"none" entry that
clears the style outright via a new clearStyle op. Buttons reflect the current
selection's color and carry accessible onClickLabels; swatches carry their own
contentDescriptions.

Closes #74
Closes #75

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 20:52:09 -05:00
Jason Ross 92c97a386b Merge main into perf-186-message-open 2026-07-02 20:43:31 -05:00
Jason Ross 62c9279700 Merge pull request #185 from JMR-dev/feat-155-yahoo-app-password
feat(accountsetup): add Yahoo app-password/2FA help in onboarding
2026-07-02 20:43:02 -05:00
JMR-devandClaude Opus 4.8 aafb4f8f6a perf(reader): render message body once, move openMessage IO off-main, drop wasted work
Follow-up to #148: opening an already-cached message was still slow. Three fixes
on the cached-open critical path (issue #186).

Fix 1 - WebView renders once. The reader resolved cid: inline images AFTER the
first render, so the AndroidView update key (which included inlineImages.keys)
changed and reloaded the whole document a second time for any inline-image email.
ReaderViewModel now resolves inline images and folds them into the SAME state
update as the body, and HtmlBody drops inline images from the reload key, so the
WebView loads exactly once and a late inline-image change never reloads. The
WebView is also destroyed onRelease so it (and its Context) is not leaked.
Pool/pre-warm is left as a TODO (leak-prone; single-render is the dominant win).

Fix 2 - openMessage does no wasted work for a cached, already-read message. Added
a body-less MessageRouting projection (mirrors MessageSummary, no migration);
the routing/flag callers (openMessage's first read, downloadAttachment, setStarred,
deleteMessage, expunge, moveByRole/moveToFolder, buildReplyDraft, prefetchMessage)
route on it, and getById (SELECT *) is reserved for the single read that returns
the body. imapParamsFor (Keystore decrypt + DataStore read) is resolved lazily,
only in the fetch / SEEN-push branches; the cached+read path also skips the
account lookup. De-duped the inlineImages attachment N+1 via a shared
ensureAttachmentFile helper that takes the already-resolved account/folder.

Fix 3 - repository IO off the main thread. openMessage, inlineImages,
downloadedAttachmentParts, and downloadAttachment now run in
withContext(Dispatchers.IO), so their DB/file/crypto work no longer runs on the
Main.immediate viewModelScope during the open animation.

Reader behavior (content, read/SEEN semantics) is unchanged; does not touch the
async SEEN network push handled separately by #170.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 20:33:07 -05:00
Jason Ross 046c21cdaa Merge main into feat-155-yahoo-app-password 2026-07-02 20:32:49 -05:00
Jason Ross 5ee1cbeecf Merge pull request #184 from JMR-dev/feat-71-strikethrough
feat(compose): add strikethrough to the formatting toolbar
2026-07-02 20:32:20 -05:00
Jason Ross ec88548f25 Merge main into feat-71-strikethrough 2026-07-02 20:21:11 -05:00
Jason Ross 1d63d6c7e1 Merge pull request #170 from JMR-dev/fix-148-async-seen-flag
fix(reader): propagate SEEN flag off the message-open critical path
2026-07-02 20:20:36 -05:00
Jason Ross ecbc052389 Merge main into fix-148-async-seen-flag 2026-07-02 20:08:15 -05:00
Jason Ross 455948774d Merge main into feat-155-yahoo-app-password 2026-07-02 20:08:14 -05:00
Jason Ross 0d0bb0d8fe Merge main into feat-71-strikethrough 2026-07-02 20:08:13 -05:00
Jason Ross 421f113349 Merge pull request #178 from JMR-dev/feat-162-advanced-settings-hierarchy
feat(settings): reorder the Advanced settings section
2026-07-02 20:07:42 -05:00
Jason Ross 31771e3a4e Merge main into feat-71-strikethrough 2026-07-02 19:56:24 -05:00
Jason Ross 49e22d1873 Merge main into feat-155-yahoo-app-password 2026-07-02 19:56:22 -05:00
Jason Ross 228ea83288 Merge main into feat-162-advanced-settings-hierarchy 2026-07-02 19:56:22 -05:00
Jason Ross 0ef26da933 Merge pull request #173 from JMR-dev/feat-150-battery-deeplink
feat(onboarding): deep-link battery step nearer the per-app background-activity screen (best-effort)
2026-07-02 19:55:54 -05:00
Jason Ross d3f0ca46dd Merge main into feat-155-yahoo-app-password 2026-07-02 19:44:32 -05:00
Jason Ross 73f69c5326 Merge main into feat-150-battery-deeplink 2026-07-02 19:44:32 -05:00
Jason Ross aff7133bbb Merge main into feat-162-advanced-settings-hierarchy 2026-07-02 19:44:31 -05:00
Jason Ross 47b7efade7 Merge main into fix-148-async-seen-flag 2026-07-02 19:44:29 -05:00
Jason Ross 6a3b3d5a92 Merge main into feat-71-strikethrough 2026-07-02 19:44:29 -05:00
Jason Ross e22e6c14dd Merge pull request #175 from JMR-dev/feat-163-default-account
feat(settings): let the user set a default mail account
2026-07-02 19:44:01 -05:00
Jason Ross bd1f09fc06 Merge main into feat-71-strikethrough 2026-07-02 19:33:18 -05:00
Jason Ross 565a4ebfa8 Merge main into fix-148-async-seen-flag 2026-07-02 19:33:17 -05:00
Jason Ross d6100462a7 Merge main into feat-163-default-account 2026-07-02 19:33:16 -05:00
Jason Ross 56a6776f67 Merge main into feat-162-advanced-settings-hierarchy 2026-07-02 19:33:15 -05:00
Jason Ross 96fb91ce8c Merge main into feat-150-battery-deeplink 2026-07-02 19:33:13 -05:00
Jason Ross 7b2bef32fc Merge main into feat-155-yahoo-app-password 2026-07-02 19:33:12 -05:00
Jason Ross 7d92aa6feb Merge pull request #181 from JMR-dev/fix-157-notification-open-message
fix(notifications): reliably open the tapped message from a new-mail notification
2026-07-02 19:32:42 -05:00
JMR-devandClaude Opus 4.8 6ff988aaec feat(accountsetup): add Yahoo app-password/2FA help in onboarding
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>
2026-07-02 19:25:24 -05:00
Jason Ross 52d99d2bf9 Merge main into feat-150-battery-deeplink 2026-07-02 19:21:15 -05:00
Jason Ross 41d06c5c77 Merge main into fix-157-notification-open-message 2026-07-02 19:21:13 -05:00
Jason Ross 5eebe2e2b1 Merge main into feat-162-advanced-settings-hierarchy 2026-07-02 19:21:12 -05:00
Jason Ross d4412ce5f3 Merge main into feat-163-default-account 2026-07-02 19:21:11 -05:00
Jason Ross e2ab1b8953 Merge main into fix-148-async-seen-flag 2026-07-02 19:21:10 -05:00
Jason Ross 64918731eb Merge main into feat-71-strikethrough 2026-07-02 19:21:09 -05:00
Jason Ross 66534d6a0e Merge pull request #183 from JMR-dev/feat-159-report-required-email
feat(reporting): require a reply-to email and a 200-char minimum on problem reports
2026-07-02 19:20:14 -05:00
JMR-devandClaude Opus 4.8 60a8e7df1b feat(compose): add strikethrough to the formatting toolbar
The rich-text engine already fully supported RichStyle.Strikethrough
(HTML serialization, parsing, and rendering); only the toolbar control
was missing. Adds an "S" FormatButton next to Bold/Italic/Underline,
wired the same way (onToggleStyle + isStyled toggle state), plus the
format_strikethrough string resource for its onClickLabel.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 19:19:14 -05:00
Jason Ross 7201ff3eaa Merge branch 'main' into fix-148-async-seen-flag 2026-07-02 19:16:12 -05:00
Jason Ross fce2ae981c Merge main into feat-163-default-account 2026-07-02 19:09:04 -05:00
Jason Ross e475c30f0a Merge main into feat-162-advanced-settings-hierarchy 2026-07-02 19:09:03 -05:00
Jason Ross 68e4e2471c Merge main into fix-157-notification-open-message 2026-07-02 19:09:02 -05:00
Jason Ross 2b38d06c6d Merge main into feat-159-report-required-email 2026-07-02 19:09:01 -05:00
Jason Ross 036df6b2fe Merge main into feat-150-battery-deeplink 2026-07-02 19:09:00 -05:00
Jason Ross 9e2b4bf49f Merge pull request #180 from JMR-dev/feat-153-icloud-onboarding-help
feat(accountsetup): add iCloud app-password/2FA help in onboarding
2026-07-02 19:08:34 -05:00
Jason Ross 6cf15c5af5 Merge branch 'main' into feat-153-icloud-onboarding-help 2026-07-02 18:57:57 -05:00
JMR-devandClaude Opus 4.8 2151bc6d7c feat(reporting): require a reply-to email and a 200-char minimum on problem reports
Adds friction to the "Report a Problem" form: a required email field (basic
local-part@domain.tld validation), a required consent notice about being
contacted at that address, and a 200-character minimum on the comment field
with a live "x/200" counter that turns red (with the field outline) until the
threshold is met. Submit stays disabled until both the comment and email are
valid, mirroring and extending the existing SUBMITTING gate. The email rides
along on DebugReport (userEmail) so it round-trips through the storage JSON
and the exact payload that's previewed, copied, saved, and POSTed. The new
ViewModel-level guard on submit() also fully integrates with the #161
success-confirmation dialog: invalid attempts never reach SUBMITTING/SUCCEEDED,
so the dialog flow is unaffected.

Closes #159

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 18:52:21 -05:00
Jason Ross 4b61c5f875 Merge branch 'main' into feat-150-battery-deeplink 2026-07-02 18:48:12 -05:00
Jason RossandClaude Opus 4.8 3c2bd0b138 ci: run autoupdate in the CI_CD environment so it can read AUTOUPDATE_TOKEN (#182)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 18:45:39 -05:00
JMR-devandClaude Opus 4.8 69be8a5c69 fix(notifications): reliably open the tapped message from a new-mail notification
MainActivity.onCreate only parsed the incoming intent (pendingCompose /
pendingOpenMessageId) when savedInstanceState == null, on the assumption
that a non-null value always means a config-change recreation (e.g.
rotation), where Android redelivers the same, already-handled intent and
re-parsing would just navigate to a duplicate destination.

But Android also passes a restored, non-null savedInstanceState when it
recreates the activity after the process was killed in the background and
is then relaunched by tapping a notification. There, intent is the new
tap, not a replay, but the guard swallowed it exactly like a rotation, so
pendingOpenMessageId was never set and the tap silently landed wherever
the restored back stack was (typically the mailbox) instead of the
message. That's the #157 regression from the original fix in a6ec00d
(#56). pendingCompose (mailto:/share intents) went through the identical
guard and had the same latent bug.

Replace the savedInstanceState check with IntentHandledMarker, which
marks the Intent instance itself once parsed. A config-change recreation
redelivers that same marked instance, so it's correctly skipped; a
genuinely new intent -- warm via onNewIntent or cold via onCreate after a
process-death relaunch -- is never marked yet, so it's always parsed.
This dedupes on the intent's own identity instead of an unreliable proxy
for it, so it can't confuse the two recreation paths.

Adds IntentHandledMarkerTest covering the marking contract: unhandled on
first look, recognized as handled on a redelivered instance, and still
unhandled on a freshly constructed (but content-equal) instance -- the
process-death case.

Closes #157

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 18:45:18 -05:00
Jason Ross 6490e5e461 Merge branch 'main' into feat-150-battery-deeplink 2026-07-02 18:37:23 -05:00
Jason RossClaude Opus 4.8github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
5f1e611886 feat(reporting): show a clear confirmation dialog after submitting a problem report (#171)
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>
2026-07-02 23:36:36 +00:00
JMR-devandClaude Opus 4.8 72ee1d3774 feat(accountsetup): add iCloud app-password/2FA help in onboarding
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>
99f6ef19e4 fix(onboarding): request notification permission after welcome screen renders (#168)
* 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>
2026-07-02 23:23:30 +00:00
Jason RossandClaude Opus 4.8 f353bdc5ec test(compose): add RichTextEditor unit + ComposeScreen UI coverage (#176)
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>
2026-07-02 23:09:40 +00:00
Jason RossandClaude Opus 4.8 e72a9b15c6 docs(compose): correct RichTextEditor toolbar accessibility KDoc (onClickLabel, not contentDescription) (#179)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 17:52:48 -05:00
JMR-devandClaude Opus 4.8 30c5251a68 feat(settings): reorder the Advanced settings section
Reorder the Advanced block's SwitchRows to Push Mail, Load Remote
Images, Encrypt Local Cache, Require Screen Lock, then Allow insecure
STARTTLS fallback (moved last). Relocate the Background battery usage
row out of Advanced entirely and into the main settings list, directly
above local retention ("Storage on this device"), since it's common
enough (OEM battery optimization delaying push mail) that it shouldn't
be hidden behind "Advanced". Pure composable placement — no string
changes, no behavior change to any toggle.

Closes #162

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 17:46:59 -05:00
Jason RossClaude Opus 4.8github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
97d6f9303b fix(accountsetup): show 2-Step Verification link before app-password link for Gmail (#166)
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 Ross 788c450cad Merge branch 'main' into feat-163-default-account 2026-07-02 17:34:20 -05:00
JMR-devandClaude Opus 4.8 e06054afb6 feat(settings): let the user set a default mail account
Persist a defaultAccountId preference (SettingsRepository/AppSettings,
following the existing key/field/setter pattern), add a "Default account"
switch to AccountSettingsScreen, and prefer it in ComposeViewModel's
from-account fallback (fromAccountId -> valid default -> first account).
Deleting the default account clears the preference (SettingsRepository
.clearDefaultAccountId), and a stale/foreign id is validated against the
current account list before use so it can never crash or point at a
missing account.

Closes #163

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 17:28:22 -05:00
github-actions[bot] 8230eae962 Merge main into feat-150-battery-deeplink 2026-07-02 22:25:51 +00:00
Jason RossClaude Opus 4.8github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
a48d68c139 fix(mailbox): show spinner during initial folder fetch instead of empty state (#167)
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>
2026-07-02 22:25:22 +00:00
JMR-devandClaude Opus 4.8 01841e9faf feat(onboarding): deep-link battery step nearer the per-app background-activity screen (best-effort)
Spiked #150 against the AOSP Settings source (not just the reference docs): no
public, non-hidden Settings action opens the "Unrestricted/Optimized/Restricted"
screen directly for a specific package. ACTION_VIEW_ADVANCED_POWER_USAGE_DETAIL
would, but it's @hide/non-SDK; the other public battery action,
ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS, isn't package-scoped and is a worse
landing for one known app. So ACTION_APPLICATION_DETAILS_SETTINGS (one tap from
the target via "Battery" on stock/Pixel/AOSP) stays the primary target.

BatteryOptimizationManager.settingsIntent() is restructured into a verified,
never-dead-end fallback chain: try app-details, and if it doesn't resolve on
some device, fall back to the battery-optimization list rather than nothing.
The ordering/selection logic is extracted into a small Android-free helper so
it's directly unit-testable; a new instrumented test checks the real candidate
intents/order against a real PackageManager.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 17:19:42 -05:00
Jason RossandClaude Opus 4.8 26d4295c30 feat(settings): reorder top-level settings sections and add appearance subtext (#169)
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>
2026-07-02 22:08:30 +00:00
JMR-devandClaude Opus 4.8 89c9f688a1 fix(reader): propagate SEEN flag off the message-open critical path
openMessage() was running a live imapClient.setFlag(SEEN) IMAP round trip
(connection + STORE) before returning whenever a message's body was already
cached but unread — purely to mark it read on the server. That network call
sat on the reader's critical path even though nothing needed for rendering
(body/attachments) required it, making "open an already-downloaded message"
feel slow (#148).

The local isRead flag is now set immediately (optimistic, local-only) and
openMessage returns without awaiting the SEEN push. The push itself runs on
a new application-lifetime backgroundScope (same CoroutineScope(SupervisorJob()
+ Dispatchers.IO) pattern already used by LibreMailApplication.appScope and
IdleService.scope), with a bounded retry (3 attempts, short backoff) since
today's folder sync deliberately never overwrites local read/star flags with
server state (see MessageDao.updateHeaderContent) and therefore would not
otherwise re-drive a push that never reached the server.

Closes #148

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 16:58:56 -05:00
Jason RossandClaude Opus 4.8 31639fdacc fix(mailbox): anchor account-switcher dropdown to its trigger (#165)
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>
2026-07-02 21:50:00 +00:00
Jason Ross f535561f64 Merge pull request #131 from JMR-dev/spike-imap-connection-reuse
spike(imap): prototype flag-gated connection reuse for folder-open
2026-07-02 15:07:05 -05:00
Jason Ross 5130597447 Merge branch 'main' into spike-imap-connection-reuse 2026-07-02 14:55:53 -05:00
Jason Ross 0a8a463677 Merge pull request #146 from JMR-dev/ci-autoupdate
ci: auto-update armed PRs; drop dead merge_group trigger
2026-07-02 12:48:30 -05:00
JMR-devandClaude Fable 5 d0a5ccb10d ci: auto-update armed PRs; drop dead merge_group trigger
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 12:48:23 -05:00
Jason Ross 1f65743631 Merge pull request #145 from JMR-dev/refactor-keystore-crypto-base
refactor(security): de-duplicate AES-GCM Keystore plumbing and unify the authenticator policy
2026-07-02 12:33:28 -05:00
JMR-devandClaude Fable 5 d424abc6d3 refactor(security): de-duplicate AES-GCM Keystore plumbing and unify authenticator policy
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>
2026-07-02 12:21:09 -05:00
Jason Ross 73d6a44a3f Merge pull request #144 from JMR-dev/test-applock-security-core
test(security): cover the app-lock security core (seal exchange, unlock classification, policy table)
2026-07-02 11:55:51 -05:00
Jason Ross 948f3f1bda Merge branch 'main' into test-applock-security-core 2026-07-02 11:43:51 -05:00
JMR-devandClaude Fable 5 5777967375 test(security): cover the app-lock security core
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>
2026-07-02 11:41:40 -05:00
Jason Ross 6bc5b90315 Merge pull request #140 from JMR-dev/fix-reader-inline-images
fix(reader): render inline cid: images in HTML emails
2026-07-02 11:36:43 -05:00
Jason Ross 8a8dbcb4d2 Merge branch 'main' into fix-reader-inline-images 2026-07-02 11:25:41 -05:00
Jason Ross 4a5fd35f2b Merge pull request #137 from JMR-dev/fix-providedatabase-anr
fix(di): defer database provisioning off the Hilt inject path
2026-07-02 11:23:15 -05:00
Jason Ross d7594c0b39 Merge branch 'main' into fix-providedatabase-anr 2026-07-02 11:11:42 -05:00
Jason Ross 7a2e3380e9 Merge pull request #138 from JMR-dev/fix-applock-restart-recovery
fix(security): harden app-lock recovery restart (self-restart race + lost syncNow enqueue)
2026-07-02 11:10:28 -05:00
Jason Ross a5c94872e0 Merge branch 'main' into fix-applock-restart-recovery 2026-07-02 10:58:53 -05:00
Jason Ross 4287c074d4 Merge pull request #135 from JMR-dev/refactor-db-file-backup-sot
refactor(security): derive backup exclusion set from DatabaseFiles
2026-07-02 10:58:05 -05:00
Jason Ross 2848937309 Merge branch 'main' into refactor-db-file-backup-sot 2026-07-02 10:45:19 -05:00
JMR-devandClaude Fable 5 3971e89d1e fix(reader): render inline cid: images in HTML emails
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>
2026-07-02 10:44:05 -05:00
Jason Ross 24df11a2e4 Merge pull request #132 from JMR-dev/feat-contacts-permission-onboarding
feat(contacts): move contacts permission to onboarding + settings
2026-07-02 10:40:26 -05:00
Jason Ross ec418797f9 Merge branch 'main' into refactor-db-file-backup-sot 2026-07-02 10:33:56 -05:00
Jason Ross 1ae19797d3 Merge branch 'main' into spike-imap-connection-reuse 2026-07-02 10:33:49 -05:00
Jason Ross 3b210059db Merge branch 'main' into fix-providedatabase-anr 2026-07-02 10:33:15 -05:00
Jason Ross 579d74e5e0 Merge branch 'main' into fix-applock-restart-recovery 2026-07-02 10:33:00 -05:00
Jason Ross a0e3ffb1f1 Merge branch 'main' into feat-contacts-permission-onboarding 2026-07-02 10:28:21 -05:00
Jason Ross 3ea8621163 Merge pull request #139 from JMR-dev/ci-merge-queue-trigger
ci: trigger on merge_group for GitHub merge queue
2026-07-02 10:21:53 -05:00
JMR-devandClaude Fable 5 c84b0605cf ci: trigger on merge_group so the GitHub merge queue can gate PRs
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 10:21:47 -05:00
Jason Ross 6758c833b0 Merge branch 'main' into refactor-db-file-backup-sot 2026-07-02 10:14:38 -05:00
Jason Ross a6e213490a Merge branch 'main' into fix-providedatabase-anr 2026-07-02 10:14:28 -05:00
Jason Ross f8ce83d5cb Merge branch 'main' into spike-imap-connection-reuse 2026-07-02 10:14:11 -05:00
Jason Ross 9edee519fa Merge branch 'main' into feat-contacts-permission-onboarding 2026-07-02 10:12:04 -05:00
JMR-devandClaude Fable 5 15c4cd9ae8 fix(security): harden app-lock recovery restart
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>
2026-07-02 10:09:21 -05:00
Jason Ross 7e7e92bb02 Merge pull request #136 from JMR-dev/feat-reader-attachment-accordion
feat(reader): collapse extra attachments into an accordion
2026-07-02 10:08:35 -05:00
JMR-devandClaude Fable 5 ed9b9e2742 fix(di): defer database provisioning off the Hilt inject path
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>
2026-07-02 10:01:46 -05:00
JMR-devandClaude Fable 5 ae37823aec feat(reader): collapse extra attachments into an accordion
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>
2026-07-02 09:56:56 -05:00
Jason Ross 938554a6eb Merge branch 'main' into feat-contacts-permission-onboarding 2026-07-02 09:47:21 -05:00
JMR-devandClaude Fable 5 a7a7c323b5 refactor(security): derive backup exclusion set from DatabaseFiles
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>
2026-07-02 09:45:54 -05:00
Jason Ross 275a74d81a Merge pull request #130 from JMR-dev/perf-unified-inbox-paging
perf(mailbox): page the unified "All inboxes" list (#124)
2026-07-02 09:45:50 -05:00
JMR-devandClaude Fable 5 d877f50fd9 feat(contacts): move contacts permission to onboarding + settings
Recipient autocomplete's READ_CONTACTS permission was requested lazily on
every compose-screen open (a LaunchedEffect(Unit)), re-prompting users who
had declined. Move the request to a dedicated, skippable onboarding step and
add a Settings entry to turn it on later, each with an in-context rationale.

- #127: new skippable ONBOARDING_CONTACTS step (mirrors the battery step),
  requested once. ComposeScreen no longer prompts; it only reads the current
  grant on resume, so a grant made later (e.g. from Settings) still takes
  effect the next time compose opens.
- #128: the onboarding step and the Settings request show a short rationale
  (contacts are used only for on-device autocomplete, never uploaded) and
  handle shouldShowRequestPermissionRationale so a re-request explains itself.
  docs/play-permissions.md updated to match.
- #129: Settings -> Contacts -> Recipient autocomplete reflects on / off /
  blocked-in-settings; requests in-app when grantable, deep-links to the app's
  system settings when permanently denied.

Graceful degradation is preserved: ContactsRepository.search still runCatch-es,
ComposeViewModel.searchContacts() still guards on contactsAllowed, and the
suggestion list still renders only when non-empty.

Adds a pure ContactPermissionDecision (JVM unit-tested), extends the onboarding
view-model tests, and adds Compose UI tests for the onboarding step
(skip / grant / deny / rationale) and the Settings row states.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 09:37:40 -05:00
Jason Ross 5994ee965e Merge branch 'main' into perf-unified-inbox-paging 2026-07-02 09:35:11 -05:00
Jason Ross 86e80a41fc Merge pull request #118 from JMR-dev/fix-accounts-out-of-cache-db
fix(security): keep accounts/credentials out of the auth-bound cache DB (#111)
2026-07-02 09:33:02 -05:00
JMR-devandClaude Fable 5 8bfc31f17c spike(imap): prototype flag-gated connection reuse for folder-open
Prototype the per-account connection reuse the #125 investigation recommended
and deferred, behind an OFF-by-default flag so it cannot destabilize `main`.

- ImapConnectionCache: keeps one authenticated Store alive per account, guarded
  by a per-account mutex, keyed by connection identity (not the rotating
  secret), with lazy catch-and-retry-once stale handling. No eviction policy
  yet beyond an explicit closeReusedConnections() hook.
- ImapClient gains a `reuseConnections` flag (default false via the @Inject
  no-arg constructor). With it off, withStore is byte-for-byte the previous
  connect + LOGOUT-per-call; with it on, calls borrow the kept-alive Store.
- ImapFolderOpenLatencyTest flips the flag on: the same real-IMAP operations
  that cost N connections / N LOGINs collapse to 1 connection / 1 LOGIN, with
  the necessary per-open EXAMINE unchanged (proven via CountingImapProxy +
  GreenMail; localhost is ~0 RTT so this proves structure, not wall-clock).
- docs/perf/issue-125-connection-reuse-spike.md: prototype design, the
  flag-off-vs-on proof, per-decision trade-offs, and the refined real-device
  validation plan. References #125; does not close it (needs device validation).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 09:29:55 -05:00
JMR-devandClaude Fable 5 f8d03a4343 perf(mailbox): page the unified "All inboxes" list (#124)
The unified inbox query (WHERE folder = ?, no accountId) has no folder-leading
index, so it scans in timestamp order and materializes the whole unified inbox
(~4k rows at a 20k cache) into memory on every emission. Apply Paging 3 to the
unified browse path so query, mapping, and recomposition cost scale with the
visible window, not the total cache.

- MessageDao.pagingUnifiedFolderSummaries: a PagingSource over the folder's
  synced rows (inInbox = 1); unified search keeps the whole-folder query so it
  can still surface transient server-search hits.
- MailRepository.pagedUnifiedFolderMessages: a Pager (pageSize 40, initialLoad
  120, no placeholders) mapping summaries to domain.
- MailboxViewModel.pagedMessages: paged while browsing the unified inbox, else
  empty; the messages list flow stays empty in that state so the whole cache is
  never materialized. Selection captures each row's accountId at tap time, so
  "Move" still resolves the selection's account without an in-memory list.
- MailboxScreen renders the unified browse list via collectAsLazyPagingItems;
  per-account and search views render the flat list unchanged (issue #86 stays
  flat).

Profiling (docs/perf/issue-124-unified-inbox-paging.md) on an api29 emulator:
current whole-inbox first-emit ~24.6 ms at a 20k cache vs. the paged first page
~6.8 ms and flat regardless of cache size (~3.6x). EXPLAIN QUERY PLAN shows the
paged query still stops early on the existing timestamp index, so no
(folder, ...) index and no schema migration are added.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 08:49:52 -05:00
JMR-devandClaude Fable 5 e53a553398 fix(security): copy account tables by shared columns, not SELECT *
Device upgrade testing surfaced a crash: on a cache last written before
v13, account_settings has 4 columns (accountId, signature,
signatureEnabled, notificationsEnabled) but the destination table has 6
(retentionCount/retentionMonths were added at v13). The migrator ran
`INSERT OR IGNORE INTO account_settings SELECT * FROM cache...`, which
supplied 4 values for 6 columns and threw SQLiteException — and because
the done-flag is only set after a successful copy, every launch re-ran
and re-crashed (crash loop).

AccountDataMigrator now copies each table by the column names present in
BOTH the freshly-created destination and the (possibly older) source, so
columns the source lacks take the destination's defaults instead of
overflowing the value list. Verified on-device: the upgrade migrates a
pre-v13 install cleanly and the account stays signed in (sync/backfill
workers run). Regression test seeds a v12 cache and asserts the copy.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 08:47:05 -05:00
Jason Ross 37e6115b45 Merge branch 'main' into fix-accounts-out-of-cache-db 2026-07-02 08:41:34 -05:00
Jason Ross 01a42a5eb1 Merge pull request #126 from JMR-dev/investigate-imap-folder-latency
test(imap): investigate folder-open round-trip latency (#125)
2026-07-02 08:35:40 -05:00
JMR-devandClaude Fable 5 ec5e3088c0 chore(schema): export v16 cache schema after rebase on main
Main advanced to @Database v15 (the #66 folder hierarchyDelimiter
migration). Renumbered the account-tables-drop migration 14->15 to
15->16 and bumped the cache DB to v16; this exports the v16 schema
(main's v15 delimiter schema minus the moved account tables). Main's
own 15.json is kept unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 08:27:53 -05:00
JMR-devandClaude Fable 5 7529a8fa7d fix(test): correct AccountDataMigratorTest assertions
Two on-device assertion failures (green on JVM compile, red on the
emulator):

- migratorDdlMatchesExportedAccountDatabaseSchema built its expected DDL
  by substituting the schema's `${TABLE_NAME}` placeholder with a
  backtick-wrapped name, but the exported createSql already wraps the
  placeholder in backticks — producing a double-backticked identifier
  that never matched the (correct, single-backticked) migrator DDL.
  Substitute the bare name so the guard compares like-for-like.
- movesEveryAccountTableOutOfAPlaintextCache asserted signatureEnabled
  was false, but the seed row sets it to 1 (true). Assert the seeded
  values for both booleans so a true and a false each round-trip.

The production migrator DDL and drop logic were already correct; only
the tests were wrong.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 08:25:26 -05:00
JMR-devandClaude Fable 5 d9d50f1903 fix(test): make instrumented migrator tests return Unit
`@Test fun x() = runBlocking { ... }` whose block ends in `.apply { }`
returns the DB (non-Unit), so JUnit4 rejects the whole class at runtime
with InvalidTestClassError ("method should be void") — which compiles
fine locally but fails every E2E job on the emulator. Use
`runBlocking<Unit>` (the existing DatabaseEncryptionTest idiom) so the
methods are void while keeping the expression body ktlint expects.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 08:25:26 -05:00
JMR-devandClaude Fable 5 9d70bc2932 fix(security): move accounts/credentials to a non-auth-bound database
Accounts, credentials, per-account settings and signatures lived in the
same libremail.db that SQLCipher encrypts under the auth-bound passphrase
when app-lock + encrypted-cache are on. A genuine key invalidation
(biometric re-enrollment or lock removal/re-add) made that file
undecryptable, and the "clear + re-sync" recovery wiped the accounts and
stored credentials along with the mail cache, dropping the user into
onboarding (issue #111).

Move those four tables into a new plaintext AccountDatabase
(libremail-accounts.db) that is never bound to the auth key. Credentials
stay AES-GCM sealed at the column level by the surviving non-auth
KeystoreCrypto master key, so the only secret never touches disk in the
clear. A cache-key invalidation now wipes only libremail.db; the user
stays signed in.

- AccountDatabase (v1) + AccountDatabaseModule; the cache DB drops to v15
  via MIGRATION_14_15. DAOs are unchanged and re-provided from the new DB,
  so no injection site changes.
- AccountDataMigrator performs the one-time cross-DB copy at startup,
  before Room opens either database. It attaches the cache (with its
  resolved passphrase, so an encrypted source is handled) and copies with
  INSERT OR IGNORE. It is crash-safe and idempotent: the source is dropped
  only by MIGRATION_14_15 after the copy, a re-run never duplicates or
  overwrites, and it runs after the clear-pending wipe so an unrecoverable
  cache degrades to "nothing to move" instead of blocking.
- Exported schemas for both databases; MigrationTest asserts the account
  rows/backfills survive to v14 then are dropped at v15, plus a dedicated
  14->15 test. AccountDataMigratorTest covers the plaintext + encrypted
  copy, idempotency, a DDL-vs-Room drift guard, and end-to-end survival of
  a simulated cache wipe.

Closes #111

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 08:25:25 -05:00
JMR-devandClaude Fable 5 b0bb942a02 test(imap): measure folder-open round-trip structure (#125)
Investigate IMAP folder-open latency (follow-up to #86). Localhost GreenMail
has ~0 RTT, so real wall-clock latency can't be measured here; instead this
pins the folder-open round-trip STRUCTURE deterministically.

Finding: ImapClient.withStore wraps every operation in its own short-lived
Store, so each folder-open pays a full CONNECT + TLS + LOGIN + EXAMINE +
FETCH + LOGOUT. Only EXAMINE + FETCH is intrinsic to opening a folder; the
whole connection-setup group is avoidable on the 2nd+ operation if a
connection were reused. Optimistic render-from-cache already exists
(selectFolder renders cached rows; the network sync is a background refresh).

Adds:
- CountingImapProxy: a localhost TCP proxy that forwards a cleartext IMAP
  session to GreenMail while counting TCP connections and parsing IMAP
  command words.
- ImapFolderOpenLatencyTest: asserts the current no-reuse behaviour (N opens
  => N connections and N LOGINs; list+read => 2 connections) against a real
  in-process IMAP server. Doubles as the harness to validate a future
  connection-reuse fix (flip the counts to assert reuse).
- docs/perf/issue-125-imap-folder-open.md: the per-open round-trip sequence,
  avoidable vs. necessary round-trips, and the recommended per-account
  connection-reuse/keep-alive mitigation with its IDLE / thread-safety /
  battery / stale-connection constraints.

Analysis + harness only; the connection-reuse fix is deferred pending
real-network + real-device measurement (see the doc's measurement plan), so
this references #125 without closing it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 08:24:18 -05:00
Jason Ross 48333ab01e Merge pull request #122 from JMR-dev/feat-persist-imap-delimiter
feat(folders): persist the server-reported IMAP hierarchy delimiter
2026-07-02 08:01:47 -05:00
Jason Ross 585b674450 Merge branch 'main' into feat-persist-imap-delimiter 2026-07-02 07:51:05 -05:00
Jason Ross 40a079df5b Merge pull request #123 from JMR-dev/perf-mailbox-message-loading
perf(mailbox): scope the message-list query to the viewed folder in SQL
2026-07-02 07:46:18 -05:00
Jason Ross 4db91ad893 Merge branch 'main' into perf-mailbox-message-loading 2026-07-02 07:35:36 -05:00
JMR-devandClaude Fable 5 e7bb69d2ac perf(mailbox): scope the message-list query to the viewed folder in SQL
The mailbox list observed the entire `messages` table (observeSummaries, no
WHERE/LIMIT), mapped every cached row to a domain Message, and filtered down to
the visible account+folder in MailboxViewModel — so its cost scaled with the
whole cache and re-ran on every write to `messages` (IDLE delivery, a flag
toggle, a backfill page, any folder sync). On a 20k-row cache that is ~125 ms of
work per unrelated write.

Push the account/folder filter into SQL (observeFolderSummaries /
observeUnifiedFolderSummaries, exposed via observeFolderMessages /
observeUnifiedFolderMessages) and flatMapLatest the ViewModel over the selected
account+folder. The only remaining client-side pass separates the normal list
from an active search over the small folder-scoped set.

Validated on an emulator against 1k/5k/20k-row caches (docs/perf/issue-86-
profiling.md): the account-scoped query is ~1.5 ms flat (~80x faster at 20k) and
is already served by the existing (accountId, folder, uid) index — so NO
composite index and NO schema migration are added. The ticket's proposed
(accountId, folder, inInbox, timestampMillis) index changes timing only within
noise and isn't even preferred by SQLite's planner. The unified "All inboxes"
view stays an O(N) folder scan (still 5.6x better) and is a follow-up for paging;
IMAP latency on folder open is a separate, unmeasured concern.

Closes #86

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 07:16:12 -05:00
JMR-devandClaude Fable 5 e28c52b6bf feat(folders): persist the server-reported IMAP hierarchy delimiter (#66)
parentOf() re-inferred the IMAP hierarchy separator from each folder's name,
relying on an unenforced invariant (displayName == fullName.substringAfterLast(
separator)) established three layers from where ImapClient reads the
authoritative JavaMail folder.separator and then discards it.

Carry that separator through FetchedFolder -> FolderEntity -> Folder and split a
folder's parent on it. Fall back to the old name inference only for legacy rows
whose delimiter is null, until the next folder refresh (delete-then-insert)
backfills the real value.

Adds a nullable folders.hierarchyDelimiter column via a Room v14 -> v15 migration
with the exported v15 schema, and registers MIGRATION_14_15 in DatabaseModule so
existing v14 installs actually upgrade (provideDatabase configures no destructive
fallback, so an unregistered migration would crash every upgrading user).

Tests: JVM unit tests for parentOf() (persisted vs. null delimiter, incl. the
case where inference cannot locate the parent) and the FetchedFolder->entity
round-trip; an instrumented v14->v15 migration test plus the chain-replay
assertion.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 07:02:09 -05:00
Jason Ross 1d6ebe8249 Merge pull request #121 from JMR-dev/refactor-applock-ui-plumbing
refactor(security): app-lock UI plumbing cleanup (snackbar, LifecycleEventEffect, LocalActivity, dead availability())
2026-07-02 04:49:28 -05:00
JMR-devandClaude Fable 5 9aaf34c95c refactor(security): app-lock UI plumbing cleanup (#104)
Behavior-preserving cleanup of the app-lock UI plumbing:

- SettingsScreen: replace the app's only Toast with the canonical
  SnackbarHostState + Scaffold(snackbarHost) + consume pattern for the
  app-lock rejection message (matches MailboxScreen); the ViewModel keeps
  the @StringRes id, resolved via LocalResources at the display boundary.
- AppLockGateHost: replace the hand-rolled DisposableEffect +
  LifecycleEventObserver with LifecycleEventEffect, and the ContextWrapper
  findFragmentActivity() walk with LocalActivity; remember the derived
  activity and the authenticate lambda.
- AppLockManager: delete the dead availability() API and the four-value
  AppLockAvailability enum (no production caller; the
  BIOMETRIC_STRONG or DEVICE_CREDENTIAL canAuthenticate combo is
  unsupported on minSdk 29). Keep isDeviceSecure() and AUTHENTICATORS.
- AppLockViewModel: derive the gated uiState from the injected gate via a
  single publish() helper instead of hand-mirroring gate.state at each
  auth site; the transient Checking cover and app-lock-off unlocked states
  stay explicit (settings/lifecycle-driven, not session-gate-driven).

Extend SettingsScreenTest with a Compose test for the rejection snackbar
(now visible to Compose semantics) and drop availability() from its fake.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 04:38:22 -05:00
Jason Ross fe4d5c02d1 Merge pull request #120 from JMR-dev/refactor-folder-label-resolver
refactor(folders): consolidate, externalize, and memoize folder-label resolution
2026-07-02 04:14:32 -05:00
Jason Ross 9782a28c57 Merge branch 'main' into refactor-folder-label-resolver 2026-07-02 04:03:30 -05:00
JMR-devandClaude Fable 5 9484c2a25b refactor(folders): consolidate, externalize, and memoize folder-label resolution
Three code-quality cleanups from the PR #54 review, all in the folder-label
plumbing so they ship as one change (adapted to the post-#108/#117 code):

#69 providerLabel: consolidate provider-brand host matching. Host->brand
knowledge now lives solely in MailProvider: forImapHost matches an entry's
imapHost plus new hostAliases (Gmail gains legacy imap.googlemail.com), and a
new companion brandFor(account) is the single seam that also recognizes
Outlook (by OAuth auth type or a precise office365.com / outlook.office.com
host, not any substring). MailProvider stays the app-password preset registry
(Outlook is not an entry). providerLabel() drops its ad-hoc host substrings.

#68 i18n: move folder-label disambiguation patterns into strings.xml. The
"base - provider", "base (parent)", and "base [path]" grammars become
folder_label_with_provider/parent/path resources, threaded into the pure
resolver as a LabelPatterns bundle whose defaults match the old literals; the
composable resolves the localized strings and passes them down.

#67 FolderDrawer: memoize label resolution, resolver early-return, fail-fast
lookups. resolvedFolderLabels hoists the role->string and pattern lookups out
of a remember() so the resolved map is rebuilt only when folders/accounts/
strings change (not every recomposition of the idle drawer). resolveDrawerLabels
returns baseLabels unchanged when nothing collides, and both map lookups use
getValue so a key miss fails loudly instead of silently un-deduplicating.

Behavior is unchanged: existing FolderLabelsTest and FolderDrawerTest
assertions (from #60/#61/#64/#108) stay green. Adds unit tests for host->brand
matching and its over-match guard, pattern-driven formatting, the early-return
identity, and fail-fast on a missing base label.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 04:01:27 -05:00
Jason Ross bc1a2b9e11 Merge pull request #119 from JMR-dev/fix-applock-lifecycle
fix(security): app-lock lifecycle consistency (grace across Back, passphrase eviction limits)
2026-07-02 03:50:14 -05:00
Jason Ross 18f52e1cb9 Merge branch 'main' into fix-applock-lifecycle 2026-07-02 03:40:26 -05:00
Jason Ross 611490a9f6 Merge pull request #117 from JMR-dev/refactor-folder-role-table
refactor(folders): derive roleOf and isServerSpecial from one attribute table
2026-07-02 03:39:35 -05:00
Jason Ross 9c73654723 Merge branch 'main' into refactor-folder-role-table 2026-07-02 03:29:37 -05:00
Jason Ross 3154318dc9 Merge pull request #115 from JMR-dev/feat-onboarding-2fa-link
feat(onboarding): link Google 2FA help from Gmail app-password step
2026-07-02 03:29:02 -05:00
JMR-devandClaude Fable 5 c39f803c97 fix(security): app-lock grace survives activity recreation; clarify passphrase eviction
Two lifecycle-consistency fixes from PR #45's review (issue #101).

1. Grace across Back/recreation. The AppLockGate state machine was a field of
   the Activity-scoped AppLockViewModel, so Back on the task root (which finishes
   the Activity and clears its ViewModelStore on API 29/30) dropped the grace
   marker and re-armed a fresh LOCKED gate, demanding full re-auth on return —
   unlike leaving via Home. Provide AppLockGate as an application-scoped @Singleton
   (SecurityModule) and inject it into the ViewModel, so the same instance is
   reused across recreation and the 30s grace behaves identically for Back and
   Home. A genuine cold start (process death) still constructs a fresh, LOCKED gate.

2. PassphraseSession eviction. The KDoc promised the passphrase is "cleared on
   lock, timeout," but nothing re-locked it on grace expiry and full eviction is
   not achievable without a DB close/reopen (provideDatabase runs once per process;
   owned by #93 / #111). Correct the KDoc to state the process-lifetime limitation
   explicitly and add a code comment at the timeout re-lock deferring full eviction
   to #93 / #111. We deliberately do NOT call session.lock() on timeout: it is the
   only separately-held copy but also drives EncryptedCacheGuard, so clearing it
   while merely locked (not exited) would stall background sync/push even though the
   DB stays open — not a correct partial eviction. No DatabaseModule changes.

Tests (JVM): extend AppLockGateTest to cover grace surviving a reused-instance
recreation within and beyond the window, and a fresh gate starting LOCKED; add
AppLockViewModelTest asserting the gate is an injected dependency the ViewModel
delegates to (onBackground/onAuthError).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 03:28:52 -05:00
Jason Ross cb4ce08b4f Merge branch 'main' into feat-onboarding-2fa-link 2026-07-02 03:18:38 -05:00
Jason Ross de6fb23989 Merge pull request #114 from JMR-dev/fix-workmanager-update-policy
fix(sync): re-enqueue periodic work with UPDATE so upgrades re-apply the schedule
2026-07-02 03:17:13 -05:00
Jason Ross 791615da06 Merge branch 'main' into fix-workmanager-update-policy 2026-07-02 03:07:05 -05:00
Jason Ross 69e58548ac Merge pull request #112 from JMR-dev/fix-backfill-gaps
fix(sync): make backfill age floor robust to out-of-order dates and guard lowestSyncedUid
2026-07-02 03:05:38 -05:00
Jason Ross 76574b66b5 Merge branch 'main' into fix-backfill-gaps 2026-07-02 02:50:56 -05:00
Jason Ross e68edb4ee3 Merge pull request #116 from JMR-dev/feat-drawer-unread-indicators
feat(drawer): unread-count badges per folder and bold accounts with unread mail
2026-07-02 02:49:42 -05:00
JMR-devandClaude Fable 5 10203d8ee9 refactor(folders): derive roleOf and isServerSpecial from one attribute table
Replaces the two hand-maintained RFC 6154 tables in FolderRole's companion
-- roleOf's attribute when-ladder and the separate SPECIAL_USE_ATTRIBUTES set,
which had already drifted (\All and \Flagged were special-use but had no role
branch) -- with a single ordered ATTRIBUTE_ROLES map from a lowercase
SPECIAL-USE attribute to the FolderRole it implies (null = server-special but
role-less). roleOf returns the first role-bearing entry the folder advertises
(insertion order preserves the old ladder's precedence); isServerSpecial treats
every key as special-use. One source of truth, so the two can no longer diverge.

Also adds \Important (RFC 8457) as a role-less special-use key, so Gmail's
[Gmail]/Important is recognized as server-provisioned and the drawer de-dup
renders "Important - Gmail" instead of leaking the raw "Important ([Gmail])"
namespace form (#62). Purely additive: no role/specialUse mapping changed for
any existing attribute, and specialUse stays a plain Boolean column re-derived
on the next folder refresh -- no Room migration needed.

Tests: extends the #64 fidelity fixtures (FolderRoleTest, FolderMapperTest) with
the \Important case, and adds table-order precedence and role-less-fallback
guards pinning the refactor behavior-for-behavior.

Closes #65
Closes #62

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 02:49:16 -05:00
Jason Ross 1f57d36292 Merge branch 'main' into fix-backfill-gaps 2026-07-02 02:37:33 -05:00
JMR-devandClaude Fable 5 9b970af2d1 feat(drawer): show per-folder unread counts and bold accounts with unread mail
Adds a live unread-count signal shared by two navigation-drawer indicators:

- #83: each folder row shows a trailing unread-count badge (capped at
  "99+"), hidden when zero. Screen readers announce the exact count via a
  plurals content description.
- #84: accounts with unread mail render their email in bold in the drawer
  account switcher and the mailbox account-filter chips.

Both derive from one efficient Room aggregate, MessageDao.observeUnreadCounts():
a COUNT(*) ... GROUP BY accountId, folder over folder-synced rows
(inInbox = 1 AND isRead = 0) that pulls no message rows into memory. Its
GROUP BY is served by the existing (accountId, folder, uid) index, so no
schema change or migration is needed. MailboxViewModel derives
folderUnreadCounts (drawer account, per folder) and accountsWithUnread
(any folder) from the one shared flow.

Unread scope is folder-synced mail in any folder, kept consistent across
both features: an account reads as bold exactly when one of its folders
shows a badge.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 02:37:24 -05:00
Jason Ross 29e26bc1a6 Merge pull request #113 from JMR-dev/test-specialuse-wiring
test(folders): cover attributes-to-specialUse wiring; fix FolderLabelsTest fidelity claim
2026-07-02 02:36:33 -05:00
JMR-devandClaude Fable 5 e8c4fc1ea1 fix(sync): re-enqueue periodic work with UPDATE so upgrades re-apply the schedule
Periodic sync, backfill, and prune were enqueued with
ExistingPeriodicWorkPolicy.KEEP, so a newer app version's interval or
constraint change never reached already-installed devices: KEEP pins the job
to the spec from whichever version first scheduled it.

Switch the three periodic schedulers to UPDATE (WorkManager 2.8+; 2.11.2 in
use), which re-applies the current spec on each app-start re-enqueue while
preserving the running period's progress. An unchanged spec is effectively a
no-op, so this never resets the schedule on launch the way REPLACE (cancel +
re-enqueue) would. The one-shot kicks (syncNow/backfillNow/pruneNow) keep
their existing policies -- they are a separate concern from #96.

SyncScheduler now injects Provider<WorkManager> (via a new WorkManagerModule)
instead of calling the WorkManager.getInstance() static directly, so the
policy is unit-testable with MockK; the Provider keeps resolution lazy to
preserve the previous initialization timing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 02:27:33 -05:00
JMR-devandClaude Fable 5 d5550cc1d9 test(folders): cover attributes-to-specialUse wiring; fix FolderLabelsTest fidelity claim
Closes the two test gaps from PR #54's review (issue #64).

1. The single production link between a server LIST response and the folder
   feature -- FetchedFolder.toEntity deriving role (FolderRole.roleOf) and
   specialUse (FolderRole.isServerSpecial) from IMAP attributes, plus
   FolderEntity.toDomain's specialUse pass-through -- had zero coverage; every
   listFolders stub returned emptyList and other tests hand-set specialUse.
   Adds FolderMapperTest pinning each RFC 6154 attribute to its expected
   (role, specialUse): \Sent/\Drafts/\Junk/\Trash/\Archive drive a role and
   mark the folder special, while \All/\Flagged mark it special but drive no
   role of their own. Adds a MailRepositoryImplTest refreshFolders case that
   slot-captures replaceForAccount and asserts persisted specialUse == [true,
   false], and extends the observeFolders test to assert the toDomain leg.

2. baseLabelsOf's doc comment claimed it builds labels "the way the drawer
   does", but it is a hand-copied literal stand-in for folderDisplayLabel
   (which is @Composable and unreachable from a JVM test). Rewords it to state
   it is an independent literal fixture that pins the de-dup logic, not the
   role-to-wording mapping, and gives FolderDrawerTest an ARCHIVE fixture whose
   server name ("All Mail") differs from its friendly label so it can actually
   discriminate role-to-label drift.

The mapping itself was correct, only uncovered -- no production change; the
attribute-to-role table refactor is #65.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 02:25:26 -05:00
JMR-devandClaude Fable 5 bce82452d0 fix(sync): make backfill age floor robust to out-of-order dates and guard lowestSyncedUid
Two code-review-derived backfill-correctness bugs (from the PR #46
review). Both govern where MailBackfiller stops and resumes paging a
folder, so they are fixed together.

(MIN(timestampMillis)), but paging descends by UID. One high-UID
message with an old Date header (moved/imported mail) dragged the
cached minimum below the cutoff and marked the folder complete while
lower-UID within-retention messages were still unfetched — a silent,
permanent gap (completion is sticky). The age floor is now decided from
each page actually fetched: only a page ENTIRELY older than the cutoff
(or folder exhaustion) ends paging, and such a prune-fodder page is not
persisted. The count floor keeps its cheap cache check — it orders by
UID like paging, so inversions can't bite it. oldestSyncedTimestamp had
no remaining caller and is removed.

migrated before the uid column existed, or a UIDFolder.getUID -1
fetch); fetchOlderThan treats beforeUid <= 1 as "nothing older", so the
folder was falsely marked fully backfilled. lowestSyncedUid now ignores
uid <= 0 rows (matching MailSyncer's minWindowUid guard), a stale
persisted boundary <= 0 is discarded on resume, and the per-page
descent takes min over positive UIDs only. A page of entirely
unresolved UIDs stalls the folder — it stays incomplete (a future
scheduled run retries) but reports no immediate more-work, so
BackfillWorker's slice-chaining loop can't busy-spin on it.

Together: #95 guarantees paging always descends with a real positive
UID boundary, and #94 makes the stop decision independent of cached
aggregates, so a placeholder or old-Dated row can no longer end
backfill early through either path. Completion stays sticky and is
declared only on positive evidence, preserving the #12/#13
backfill/pruner non-interference.

Tests (JVM, GreenMail + the existing in-memory DAO-fake harness; all
four fail against the pre-fix code): a high-UID/old-Date message must
not gap within-retention history (#94); an entirely-old page ends
paging without persisting prune-fodder (#94); a uid=0 row must not
poison the boundary (#95); a page of unresolvable UIDs stalls instead
of falsely completing (#95). MessageDaoRetentionTest pins the uid > 0
SQL guard against real SQLite and drops the removed oldestSyncedTimestamp
probe.

Closes #94
Closes #95

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 02:20:31 -05:00
JMR-devandClaude Fable 5 a547c01ff7 feat(onboarding): link Google 2FA help from Gmail app-password step
Gmail's app-passwords page rejects accounts that don't have 2-Step
Verification enabled, and the setup screen's intro text names that
prerequisite without giving the user any way to act on it. Add a
nullable MailProvider.twoFactorHelpUrl (set only for Gmail, to
Google's "Turn on 2-Step Verification" article) and surface it as a
second outlined button under the existing app-password link, reusing
the same UriHandler + snackbar failure plumbing. Yahoo and iCloud
screens are unchanged.

Closes #98

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 02:14:32 -05:00
Jason Ross 45edb792a9 Merge pull request #45 from JMR-dev/feat-screen-unlock
[needs careful review] feat(security): screen-lock app gate + auth-bound cache decrypt (#22)
2026-07-02 00:19:34 -05:00
JMR-dev 21a97222d6 Merge branch 'main' into feat-screen-unlock 2026-07-02 00:07:50 -05:00
Jason Ross 098d0ccf86 Merge pull request #108 from JMR-dev/fix-folder-label-display
fix(folders): apply label disambiguation to picker and app bar; fix self-referential and transient labels
2026-07-02 00:04:12 -05:00
JMR-dev 57126f2db0 Merge branch 'main' into feat-screen-unlock
# Conflicts:
#	app/src/main/kotlin/org/libremail/push/IdleService.kt
2026-07-01 23:58:21 -05:00
Jason Ross d6ffd10396 Merge branch 'main' into fix-folder-label-display 2026-07-01 23:53:22 -05:00
Jason Ross b1d2cff9ec Merge pull request #109 from JMR-dev/feat-sync-battery-network-policy
feat(sync): gate full-content fetch on Wi-Fi/battery; IDLE polls at low battery
2026-07-01 23:49:07 -05:00
JMR-devandClaude Fable 5 7bddc2eb58 fix(folders): apply label disambiguation to picker and app bar; fix self-referential and transient labels
Three display bugs from the PR #54 code review, all in the shared
label-resolution/presentation path:

- #59: resolveDrawerLabels was wired only into the drawer, so the
  move-to picker and the app-bar title still rendered the bare
  folderDisplayLabel — two identical "Drafts" rows in the picker could
  move mail to different folders. Both surfaces now consume the same
  resolution via a shared resolvedFolderLabels helper; picker rows
  resolve against the unfiltered target list so a row keeps its
  disambiguation even when its colliding twin is filtered out.

- #60: two top-level folders sharing a role-derived base label (e.g.
  "Sent" and "Sent Items" both classifying SENT on servers without
  SPECIAL-USE) fell through to the full-path safety net as a
  self-referential "Sent [Sent]". Colliding top-level user folders now
  tie-break on the display name: the folder actually named like the
  base keeps it, the others show their real server name. Corrected the
  resolver KDoc's overclaimed uniqueness sketch.

- #61: the drawer derived the de-dup provider suffix from drawerAccount,
  which updates before the lagging folders StateFlow during an account
  switch, so stale Gmail folders briefly rendered as "Drafts - Outlook".
  The suffix now derives from the rendered folder list's own accountId
  (providerLabelFor), keeping a stale list under its own account's brand.

Tests: FolderLabelsTest covers the role tie-break and providerLabelFor;
MailboxViewModelTest pins the switch gap with Turbine; MailboxScreenTest
drives the disambiguated move picker (moving via "Drafts - Gmail" lands
in [Gmail]/Drafts) and the app-bar title; FolderDrawerTest renders the
transient switch frame.

Closes #59, closes #60, closes #61.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-01 23:45:15 -05:00
JMR-dev bb9b7f39c4 Merge branch 'main' into feat-screen-unlock 2026-07-01 23:40:23 -05:00
Jason Ross c9d98b4ca2 Merge branch 'main' into feat-sync-battery-network-policy 2026-07-01 23:39:47 -05:00
Jason Ross 8d503abd7e Merge pull request #106 from JMR-dev/fix-html-preview-snippets
fix(mailbox): derive plain-text preview snippets from HTML bodies
2026-07-01 23:38:47 -05:00
JMR-dev 9ca53de3da Merge branch 'main' into feat-screen-unlock 2026-07-01 23:31:14 -05:00
Jason Ross c18ab42c6c Merge branch 'main' into fix-html-preview-snippets 2026-07-01 23:29:44 -05:00
Jason Ross 5c0e3af38d Merge pull request #107 from JMR-dev/feat-room-migration-tests
test(db): add Room migration tests with MigrationTestHelper
2026-07-01 23:28:59 -05:00
JMR-devandClaude Fable 5 26f9127d84 feat(sync): gate full-content fetch on Wi-Fi/battery; IDLE polls at low battery
Shared core: BatteryStatusProvider (BatteryManager one-shot +
ACTION_BATTERY_CHANGED flow) feeds SyncResourcePolicy, a pure,
unit-tested decision object; all gates are runtime-only and
self-reverting - no setting is ever mutated.

- #88: FetchPolicy now defaults to WIFI_ONLY in both the AppSettings
  default and the DataStore-read fallback, so fresh installs and
  never-touched existing installs stop bulk-downloading full content
  over cellular. An explicitly chosen policy is unaffected.
- #89: the aggressive body/attachment prefetch pauses for every
  FetchPolicy at <=20% battery in BOTH content-prefetch paths -
  MailSyncer's recent-window prefetch and MailBackfiller's
  full-history prefetch (#12) - resuming on the next sync once above
  the threshold; charging exempts. Header sync and backfill header
  paging (new-mail detection, notifications, history) are untouched.
- #90: IdleService watches battery and proactively closes its IDLE
  connections at <=20%, flipping the foreground notification to say
  mail is checked every 15 minutes (the always-scheduled periodic
  sync, re-asserted on entry); IDLE resumes at >=25% or on charger
  (hysteresis prevents threshold flapping) and catches up missed mail
  via idle()'s on-connect sync.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-01 23:18:18 -05:00
Jason Ross 657a3aefea Merge branch 'main' into feat-room-migration-tests 2026-07-01 23:17:11 -05:00
Jason Ross 89b2aca141 Merge pull request #105 from JMR-dev/feat-release-workflow
ci(release): add tag-triggered signed-release and store-publish workflow
2026-07-01 23:15:51 -05:00
JMR-devandClaude Fable 5 c06a387b3c fix(mailbox): derive plain-text preview snippets from HTML bodies
snippetOf() stripped only tag delimiters with a single regex on every
body, HTML or not: <style>/<script> text leaked into HTML snippets,
entities stayed encoded, and plain-text bodies had literal <...> text
eaten as if it were markup.

Replace it with Snippet.of(body, isHtml), which finally consults the
isHtml flag both call sites already had: HTML bodies go through
HtmlToText (script/style content dropped, tags stripped, entities
decoded), plain text gets no markup handling at all; both paths keep
the whitespace collapsing and the 140-char cap. HtmlToText's entity
decoding is now a single-pass decoder that also handles decimal/hex
numeric character references and never re-decodes produced characters.

Snippets are persisted when a body is first fetched and never
re-derived, so existing rows would keep their broken snippets forever;
a data-only v13->v14 migration re-derives every cached row's snippet
with the corrected logic (schema unchanged relative to v13, exported
14.json committed).

Closes #85

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-01 23:13:16 -05:00
JMR-dev 63b553ab8e Merge branch 'main' into feat-screen-unlock
# Conflicts:
#	app/src/main/kotlin/org/libremail/di/DatabaseModule.kt
#	app/src/main/kotlin/org/libremail/ui/settings/SettingsViewModel.kt
2026-07-01 23:12:56 -05:00
Jason Ross 2474981c9e Merge branch 'main' into feat-release-workflow 2026-07-01 23:05:29 -05:00
Jason Ross b698c17cd0 Merge pull request #46 from JMR-dev/feat-fetch-all-retention
[needs careful review] feat(sync): default fetch-all history + device-only retention (#12, #13)
2026-07-01 23:03:49 -05:00
JMR-dev 2feaa0bb30 Merge branch 'main' into feat-screen-unlock
# Conflicts:
#	app/src/main/kotlin/org/libremail/MainActivity.kt
2026-07-01 22:57:23 -05:00
Jason Ross 59e068e326 Merge branch 'main' into feat-release-workflow 2026-07-01 22:54:12 -05:00
Jason Ross 5bc2b4d6b1 Merge branch 'main' into feat-fetch-all-retention 2026-07-01 22:53:24 -05:00
Jason Ross 8cc4ea22f8 Merge pull request #97 from JMR-dev/feat-play-compliance
docs(play): add privacy policy, data-safety mapping, and permissions justification
2026-07-01 22:52:21 -05:00
JMR-dev 4e9e21e847 Merge remote-tracking branch 'origin/main' into feat-fetch-all-retention 2026-07-01 22:46:08 -05:00
Jason Ross 295312937d Merge branch 'main' into feat-play-compliance 2026-07-01 22:40:06 -05:00
Jason Ross 2dd47432ea Merge pull request #110 from JMR-dev/feat-message-options-top-bar
feat(message): move message actions from dropdown to top-bar icons
2026-07-01 22:39:22 -05:00
Jason Ross 85b4597939 Merge branch 'main' into feat-play-compliance 2026-07-01 22:31:42 -05:00
Jason Ross 0350572c9f Merge branch 'main' into feat-fetch-all-retention 2026-07-01 22:30:52 -05:00
JMR-devandClaude Fable 5 c5c2da8e68 test(db): add Room migration tests with MigrationTestHelper
Closes the gap where app/schemas was exported but never validated (#63):

- androidx.room:room-testing (androidTest) + ship the exported schemas as
  androidTest assets so MigrationTestHelper can build old-version databases.
- MigrationTest: 11->12 asserts folders.specialUse arrives defaulting to 0
  with existing rows intact; a chain-integrity test requires exactly one
  migration per version step up to the newest exported schema; a full
  v7->latest replay validates every step against its exported JSON and
  asserts seeded v7 data and each migration's backfills survive. The
  migration list is discovered from Migrations.kt and the target version
  from the exported schemas, so a future migration is covered by just
  committing its schema JSON.
- Pin kotlinx-serialization to 1.8.1 via its BOM: androidx.savedstate pins
  1.7.3 transitively (shared with androidTest by AGP 9 consistent
  resolution), and Room 2.8's schema-bundle serializers need >= 1.8.0 or
  MigrationTestHelper throws AbstractMethodError parsing the schema JSON.
  Identical to the pin already proven on the feat-fetch-all-retention
  branch, so the two merge cleanly in either order.
- Refresh comments that described migration tests as future work.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-01 22:30:14 -05:00
Jason Ross 4d26a91f59 Merge branch 'main' into feat-message-options-top-bar 2026-07-01 22:29:50 -05:00
JMR-devandClaude Fable 5 f66ec3d7fb fix(security): harden app-lock + encrypted-cache flows (PR #45 review)
Addresses 14 of the 15 confirmed findings from the max-effort review of the
screen-lock app gate. The remaining one (accounts/credentials share the
auth-bound cache DB) needs a device-tested Room migration and is filed
separately; its blast radius is reduced here by eliminating the spurious wipes.

- Cold-start deadlock: LibreMailApplication injects AccountRepository lazily so
  the Room DB is never built on the main thread before unlock.
- Passphrase source of truth: DatabaseKeyStore.resolvePassphrase() keys off
  which seal exists, not the app-lock setting; passphrase() refuses to mint a
  master key while an auth seal exists.
- Toggle-order strand: disabling app-lock reseals under the master key whenever
  an auth seal exists (not gated on the encryptCache setting).
- Crash-safe clear protocol: wipe + reset seals, then clear the flag last; set
  clear-pending before flipping app-lock off.
- isInvalidated(): treats a lapsed auth window (UserNotAuthenticated) as valid,
  and onForeground short-circuits when app-lock is off.
- unwrapSealedPassphrase: classifies all decrypt failures — no crash after a
  successful auth.
- Headless entry points: SyncWorker/SendWorker/IdleService fail fast via
  EncryptedCacheGuard instead of blocking DB construction while locked.
- sealWithMaster: deletes the orphaned auth key (no spurious later wipe).
- Lock-bypass race: AppLockGate ignores a background recorded after a foreground
  pass began; the ViewModel captures the foreground timestamp synchronously.
- FLAG_SECURE: set while app-lock is on (recents/screenshot protection).
- Resume + re-lock: the gate covers content with an opaque overlay instead of
  removing it, so no stale frame renders and in-progress state (nav, drafts)
  survives re-lock.
- Retry feedback: lock emissions carry a nonce so a retry updates the UI.

Tests: AppLockGate stale-foreground race cases + an exhaustive
KeyInvalidationPolicy table. Fast gate green + androidTest compiles.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-01 22:29:48 -05:00
JMR-devandClaude Opus 4.8 195c07aa32 Merge remote feat-fetch-all-retention (32b91a1); keep the complete latest-main merge
32b91a1 merged an older main: it dropped main's F-Droid content (docs/fdroid-compliance.md,
fastlane metadata, the build.gradle.kts dependenciesInfo block) and its CI failed only on an
E2E (30) infra flake (~110s in 'Run E2E tests'). This side merges the LATEST main, restores that
content, resolves build.gradle.kts keeping both additions, and is fast-gate + androidTest-compile
green. Supersede 32b91a1 via -s ours.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 22:28:06 -05:00
Jason Ross 67957a3961 Merge pull request #92 from JMR-dev/fix-move-by-role-specialuse
fix(mail): prefer special-use folder when resolving move-by-role destination
2026-07-01 22:20:47 -05:00
JMR-devandClaude Opus 4.8 5283d29d5d Merge branch 'main' into feat-fetch-all-retention
Resolve the build.gradle.kts conflict by keeping both additions: the
androidTest Room-schema srcDir (this branch) and main's F-Droid
dependenciesInfo block. Full fast gate + androidTest compile green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 22:18:50 -05:00
JMR-devandClaude Fable 5 1d796e3c41 feat(message): move message actions from dropdown to top-bar icons
The multi-select contextual action bar buried Archive, Spam, Move,
Select all, and the single-selection Reply/Reply All/Forward behind one
MoreVert dropdown; only Close and Delete were direct. Promote the
common actions to direct IconButtons, matching the reader app bar's
icons-not-menus pattern: Archive (Done glyph - material-icons-core has
no archive icon, so this leans on the "done = archive" mail idiom),
Spam (Warning), and Delete, each with a contentDescription for
accessibility.

The overflow keeps only the long tail: Move (no usable core glyph, per
the ticket it stays text-labeled), Select all, and the
single-selection reply actions. All conditional visibility is
preserved: Archive/Spam still hide while viewing their own role
folder, Move still requires a single-account selection, and the reply
actions still require exactly one selected message. Four 48dp actions
plus Close still fit a 320dp-wide bar; the count title just truncates
earlier.

UI tests: the direct Archive icon archives without opening the
overflow, the direct Spam icon still confirms before reporting, the
Archive icon hides inside the archive folder, and the overflow test
now keys on Select all instead of the promoted Archive.

Closes #87

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-01 22:13:25 -05:00
Jason Ross bb3dcd8217 Merge branch 'main' into fix-move-by-role-specialuse 2026-07-01 22:07:18 -05:00
Jason Ross 9f582ecac4 Merge pull request #91 from JMR-dev/feat-fdroid-compliance
chore(fdroid): add F-Droid metadata, license audit, and anti-feature docs
2026-07-01 22:06:38 -05:00
Jason Ross 32b91a181b Merge branch 'main' into feat-fetch-all-retention 2026-07-01 22:02:38 -05:00
JMR-devandClaude Fable 5 0b0f6b7018 ci(release): add tag-triggered signed-release and store-publish workflow
Rewrite the manual-dispatch release.yml into the issue-#19 pipeline:
v* tag push (or dispatch with dry-run/re-release inputs) runs the fast
CI gate, builds bundleRelease + assembleRelease signed from base64
keystore secrets (falling back to *-unsigned artifacts when unset),
generates a Conventional-Commit changelog and SHA-256 checksums, then
creates the GitHub release and fans out to secret-gated Google Play
publish (staged rollout supported), a documented Galaxy Store manual
stub, and an S3-compatible archive under releases/<tag>/. Every
credentialed stage skips with a clear notice while the store accounts
(#16/#17/#18) don't exist yet; no secret lives in the repo and
app/build.gradle.kts is unchanged. docs/release.md documents the
secrets, flows, and per-store manual fallbacks.

Part of #19

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-01 22:01:34 -05:00
JMR-devandClaude Opus 4.8 6ea02f588d fix(sync): resolve code-review findings on fetch-all history + retention
Addresses the review of PR #46 (#12/#13):
- Age-retention backfill/prune loop: mark a folder complete at the
  retention floor and resume from the persisted nextBeforeUid low-water
  mark; loosening resumes via AccountRepository.resetBackfillProgress.
- Guard the windowed reconcile bound to the lowest positive UID so a
  getUID==-1 message can't collapse it and wipe backfilled history.
- Order count-based retention by uid DESC to match the fetch window,
  ending the re-fetch/re-prune churn for high-UID/old-Date messages.
- BackfillWorker chains slices while work remains.
- Extract shared effectiveRetention / isActiveNetworkUnmetered /
  attachmentCacheDir helpers; remove dead deleteSyncedNotIn/getForAccount;
  refresh only pre-existing rows in persistBatch; add composite index
  (accountId, folder, uid) with migration + regenerated 13.json.

Adds an age-floor prune regression test. Fast gate + androidTest compile
green on JDK 21.

Follow-ups filed for below-the-cut findings: #93, #94, #95, #96.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 21:53:43 -05:00
JMR-devandClaude Fable 5 3f7024d05d docs(play): add privacy policy, data-safety mapping, and permissions justification
Repo-actionable deliverables for the Google Play compliance work (issue #17),
every claim verified against the code and the built release artifacts:

- PRIVACY.md: user-facing privacy policy (device-local mail cache, optional
  SQLCipher encryption, traffic only to the user's own mail provider,
  on-device-only contacts autocomplete, strictly local opt-in debug reports,
  no ads/analytics/tracking SDKs).
- docs/play-data-safety.md: Play Data safety questionnaire mapping -- answer
  'no data collected/shared' with per-category code evidence, the policy
  exemptions relied on, a dependency audit, and a conservative fallback.
- docs/play-permissions.md: merged-manifest permission audit (incl. the
  WorkManager-injected WAKE_LOCK / RECEIVE_BOOT_COMPLETED) with paste-ready
  Console justifications for READ_CONTACTS, POST_NOTIFICATIONS, and the
  FOREGROUND_SERVICE_DATA_SYNC declaration + demo-video script.
- docs/play-compliance.md: verified targetSdk 37 (requirement: 35+), 16 KB
  page-size compliance (all packaged .so PT_LOAD p_align=0x4000, incl.
  sqlcipher-android 4.16.0), bundleRelease AAB check, the Gmail-app-password /
  no-CASA OAuth note, the console-steps checklist with drafted content-rating
  and listing answers, and repo findings (push-mail default vs docs, README
  minSdk/app-lock drift, debug-key release fallback).
- README.md: link PRIVACY.md and note the no-Google-OAuth/no-CASA status
  (fuller README pass stays issue #20).

Part of #17.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-01 21:52:40 -05:00
JMR-devandClaude Fable 5 a6436719b1 fix(mail): prefer special-use folder when resolving move-by-role destination
resolveRoleFolder().pick() chose the destination for archive/reportSpam/
trash as the first selectable folder with the matching role, in server
LIST order. A provider's built-in folder (role via an RFC 6154 attribute,
e.g. [Gmail]/Spam via \Junk) and a same-named user folder (role via
roleFromDisplayName) can share a role, so the winner depended on which
one the server happened to LIST first — silently misrouting mail past
the provider's junk training, retention, and auto-purge.

Prefer the server-advertised special-use folder among same-role matches:
maxByOrNull { it.specialUse } picks a specialUse=true folder over
name-derived ones, and, because maxByOrNull returns the first max, keeps
the existing LIST-order behavior when no special-use folder exists.

Closes #58

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-01 21:48:46 -05:00
JMR-devandClaude Fable 5 807f2402a5 chore(fdroid): tighten accuracy of CI and KnownVuln wording in audit doc
CI runs ktlint/detekt and the E2E suites (not Android lintDebug), and the
KnownVuln rationale should not imply blanket TLS enforcement.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-01 21:46:06 -05:00
JMR-devandClaude Fable 5 db96134492 chore(fdroid): add F-Droid metadata, license audit, and anti-feature docs
Prepare for F-Droid publication (issue #16):

- docs/fdroid-compliance.md: full dependency license audit (release
  runtime classpath + buildscript classpath — all FOSS, no Play
  Services/Firebase, no non-free Gradle plugins), an anti-feature
  review of actual app behavior (none to declare: debug reporting is
  opt-in/local-only with no endpoint by default, Android Backup is
  gated off by default, Outlook OAuth is optional per-account with a
  public client id), a complete network-surface inventory, and the
  clean-room build verification (assembleRelease succeeds with no
  secrets.properties).
- app/build.gradle.kts: stop embedding AGP's dependency-info block (a
  Google-Play-encrypted dependency list in the APK signing block) in
  APKs/bundles — a known F-Droid inclusion/reproducibility blocker.
- fastlane/metadata/android/en-US/: store listing (title, short/full
  description, changelog for versionCode 1) that F-Droid reads from
  the repo; listing .txt files deliberately carry no license headers.
- docs/fdroid/org.libremail.app.yml: commented template + instructions
  for the eventual fdroiddata build recipe (submission out of scope).
- README.md: F-Droid section pointing at the above.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-01 21:43:58 -05:00
Jason Ross 0880dd7f54 Merge pull request #80 from JMR-dev/feat-compose-attachment-reminder
feat(compose): prompt before sending when a mentioned attachment is missing
2026-07-01 18:20:57 -05:00
JMR-dev d687f11518 Merge remote-tracking branch 'origin/main' into feat-compose-attachment-reminder
# Conflicts:
#	app/src/androidTest/kotlin/org/libremail/ui/compose/ComposeScreenTest.kt
#	app/src/main/kotlin/org/libremail/ui/compose/ComposeScreen.kt
2026-07-01 18:04:59 -05:00
Jason Ross 0dd6933b13 Merge pull request #82 from JMR-dev/feat-compose-collapsible-cc-bcc
feat(compose): collapse Cc/Bcc into expandable links under the To field
2026-07-01 17:29:51 -05:00
Jason Ross eeeb838658 Merge branch 'main' into feat-compose-collapsible-cc-bcc 2026-07-01 17:18:15 -05:00
Jason Ross 1828109894 Merge pull request #81 from JMR-dev/feat-richtext-foundation
feat(richtext): parameterized styles, alignment/image/base-style channels, HTML round-trip
2026-07-01 17:15:57 -05:00
Jason Ross bce597b03c Merge branch 'main' into feat-compose-collapsible-cc-bcc 2026-07-01 17:12:43 -05:00
JMR-devandClaude Fable 5 4782b24453 fix(richtext): preserve blank lines between aligned paragraphs; share span merging
Code-review fixes: an all-empty paragraph group (a blank line isolated by an
alignment split) emitted <p></p>, which the parser collapses — it now emits one
<br> per line so blank lines round-trip. The identical span-merge helper that
existed in both the parser and RichTextEditing is now a single shared
mergeSameValueSpans() in RichText.kt, and the private applyBlock/applyLink drop
their never-used default font resolver.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-01 17:05:14 -05:00
JMR-devandClaude Fable 5 671fca99a2 feat(compose): prompt before sending when a mentioned attachment is missing
Send now scans the subject and body for "attach" and its variants
(word-bounded, case-insensitive). When the text mentions one but the
message carries no attachment, an AlertDialog asks "Need to attach
anything?" — Yes returns to composing and pulses the attach button,
No sends the message as-is, and dismissing cancels the send. (#79)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-01 17:04:15 -05:00
JMR-devandClaude Fable 5 2cefc4751b feat(richtext): parameterized styles, alignment/image/base-style channels, HTML round-trip
RichStyle becomes a sealed interface (Bold/Italic/Underline/Strikethrough +
FontFamily/FontSize/FontColor/Highlight); RichTextContent gains alignments,
images, and baseStyle channels. The HTML serializer emits merged <span style>
runs, text-align on <p>/<li> (splitting merged paragraphs at alignment
boundaries), <img src="cid:…"> over the visible [image: name] token, and a
single outer <div style> for the base style. The parser is a faithful inverse
and additionally tolerates <del>/<strike>, px font sizes, #rgb colors, and
start/end alignment synonyms; unknown CSS is ignored without dropping text.

hasFormatting() covers every new channel so ComposeViewModel.normalizedHtml()
never silently drops serialized formatting. The editor carries parameterized
style identity via string annotations (libremail:style / libremail:image), maps
alignment onto ParagraphStyle ranges, holds baseStyle in separate field state,
and RichTextEditing.toggleStyle now replaces a different value of the same kind
while styleAt() answers "current value over the selection" for pickers.
ColorSwatchRow is added for the upcoming color/highlight dialogs. No UI change.

Closes #70

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-01 16:54:25 -05:00
JMR-devandClaude Fable 5 99ece7b6d7 feat(compose): collapse Cc/Bcc into expandable links under the To field
The Cc and Bcc fields now start collapsed into small left-aligned link
buttons under the To box, freeing about two field heights of vertical
space for the message body. Tapping a link expands it into the regular
input field and focuses it; a field also expands on its own when it
already carries recipients (reply-all/mailto prefill, resumed drafts)
and never re-collapses once shown, so it cannot vanish mid-edit. The
expansion state lives in the UI via rememberSaveable and survives
rotation. Moving the Bcc field also gives it the medium shape every
sibling field already had.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-01 16:20:23 -05:00
JMR-devandClaude Fable 5 f99c2df85f fix(compose): restore Bcc recipients when resuming a draft
saveOrDeleteDraft persists the Bcc line, but the init-block restore
never copied it back, so reopening a draft silently dropped its Bcc
recipients (and re-saving then lost them for good).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-01 16:20:07 -05:00
Jason Ross f5c9d4c4d2 Merge pull request #57 from JMR-dev/fix-notification-tap-opens-message
fix(notifications): open the tapped message from a new-mail notification
2026-07-01 16:13:46 -05:00
JMR-devandClaude Fable 5 a6ec00d203 fix(notifications): open the tapped message from a new-mail notification
Tapping a new-mail notification only brought the app to the foreground:
the content PendingIntent was a bare launch intent shared by every
notification, and nothing on the activity side handled a message target.

Per-message notifications now carry an explicit open-message intent —
action + id extra + a per-message data URI, so each message keeps its
own PendingIntent under filterEquals instead of all collapsing onto one
FLAG_UPDATE_CURRENT entry. MainActivity parses the id on fresh launch
and in onNewIntent and hands it to the NavHost as pending state (the
pendingCompose handoff pattern) to navigate to the reader. The group
summary keeps the plain open-the-app intent.

Fixes #56

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-01 15:51:16 -05:00
Jason Ross f1e9af53f5 Merge branch 'main' into feat-fetch-all-retention 2026-07-01 15:42:38 -05:00
Jason Ross 4a139faf61 Merge branch 'main' into feat-screen-unlock 2026-07-01 15:39:48 -05:00
Jason Ross a5644f4e02 Merge pull request #55 from JMR-dev/chore-preflight-static-analysis
chore(preflight): add ktlintCheck + detekt to the fast gate
2026-07-01 15:38:17 -05:00
JMR-devandClaude Opus 4.8 77f837e67a Merge main into feat-fetch-all-retention
Resolve the Room schema-version collision: main's PR #54 added MIGRATION_11_12 (folders.specialUse), colliding with this branch's v11->v12 uid/retention/backfill migration. Renumbered ours to MIGRATION_12_13 — the two migrations touch disjoint tables, so ours stacks cleanly on top — bumped the DB to version 13, kept main's 12.json as the v12 schema and regenerated 13.json, and renamed Migration11To12Test -> Migration12To13Test.

Verified locally: assembleDebug, testDebugUnitTest, lintDebug, ktlint, detekt, compileDebugAndroidTestKotlin, and Migration12To13Test on an API 37 emulator all pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 15:34:47 -05:00
JMR-devandClaude Opus 4.8 528cbd94c4 chore(preflight): add ktlintCheck + detekt to the fast gate
CI's "Static analysis" job runs :app:ktlintCheck :app:detekt, which the
local preflight gate did not, so style violations in test/androidTest
source sets (which lintDebug skips) failed the merge gate only after
push. Add both to the /preflight skill and mirror the change in CLAUDE.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 15:28:25 -05:00
Jason Ross 9f7540a2f6 Merge pull request #54 from JMR-dev/fix-drawer-duplicate-folders
fix(mailbox): de-duplicate folder names in the drawer
2026-07-01 15:24:57 -05:00
JMR-devandClaude Opus 4.8 e20981d083 style(test): satisfy ktlint in new folder-label tests
Body expression on the signature line (function-signature) and one
argument per wrapped line (argument-list-wrapping) in the tests added
for drawer folder de-duplication.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 15:15:24 -05:00
JMR-devandClaude Opus 4.8 eb0e649c30 fix(test): avoid observeAll() in MessageDaoRetentionTest (CI compile glitch)
The androidTest compile failed ONLY in CI with "Unresolved reference 'observeAll'"
on the single line using dao.observeAll(), while every other MessageDao call in the
same file resolved, the identical observeAll().first().map{}.toSet() in
LibreMailDatabaseTest compiled fine in the same unit, and the file compiled cleanly
locally (even `clean --no-build-cache`). That points to a Kotlin incremental-compilation
artifact specific to the newly-added file, not a code error.

Replace the observeAll()-based readback with explicit getById point lookups — a clearer
per-row assertion that also sidesteps the glitch. Verified on the API 37 emulator (5/5).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 15:08:16 -05:00
JMR-devandClaude Opus 4.8 0f479b2431 fix(mailbox): de-duplicate folder names in the drawer
The drawer rendered every standard-role folder with a generic friendly
name (e.g. "Drafts") and discarded the server name, so a Gmail account
with both a provider built-in folder and a same-named user folder showed
two identical entries (Drafts, Archive, Spam).

De-duplicate labels provider-agnostically: when 2+ folders would render
the same name, the provider's built-in special folder (identified by RFC
6154 SPECIAL-USE flags, now persisted on the folder cache) gets the
provider name appended ("Archive - Gmail"), a nested user folder gets its
parent location ("Reports (Work)"), and a top-level user folder keeps its
plain name. Only triggers on a real collision, so stock accounts are
unchanged.

Adds a `specialUse` column to the folders table (Room v11 -> v12).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 15:01:11 -05:00
JMR-devandClaude Opus 4.8 1d4103747f test(sync): assert MailMaintenanceGate serializes backfill and prune
The gate's "backfill and prune never interleave" guarantee was only argued
structurally: the MailBackfiller/MailPruner unit tests each build a throwaway,
uncontended gate, so the serialization is never exercised. Add MailMaintenanceGateTest:

- oneGateSerializesConcurrentCriticalSections: 50 coroutines contend on one gate;
  an overlap counter must never exceed 1 (guards against a per-access mutex).
- aConcurrentPruneWaitsForAnInFlightBackfillToReleaseTheGate: a real MailBackfiller
  parks inside the gate (its fetchOlderThan suspended) while a real MailPruner is
  launched concurrently; the two share ONLY the gate, and the prune provably cannot
  enter its critical section until the backfill releases.

Turns the defence-in-depth guarantee from argued to asserted.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 14:52:36 -05:00
JMR-devandClaude Opus 4.8 a73b6410a2 test(sync): tally rows offered to insertNew to prove no double-fetch
The backfiller's "no message fetched twice" claim (full-history + resume tests)
previously rested on the insertNew fake de-duping by id, so a re-fetched page was
silently absorbed and `cached.size == TOTAL` could not fail on it. Count the rows
offered to insertNew BEFORE de-dupe and assert it equals TOTAL - WINDOW, so any
re-request of an already-cached page now fails the test. This isolates the real
boundary-descent guarantee and, unlike a fetchOlderThan call-count, is independent
of BACKFILL_BATCH_SIZE.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 14:33:13 -05:00
JMR-devandClaude Opus 4.8 ec60348c89 test(sync): pin MessageDao retention/backfill boundary SQL on real SQLite
The MailPruner/MailBackfiller unit tests mock the DAO, so the queries that
actually define the device-only retention floor were never exercised against a
real database: the newest-N-by-(timestampMillis, uid) prune selection, the
strict age cutoff, the windowed reconcile that spares backfilled history, and
the lowest-uid / count / oldest floor probes the backfiller stops on.

Add an instrumented MessageDao test on an in-memory Room DB covering all of
them, including the timestamp/uid tie-break direction (a flipped ORDER BY would
locally delete the user's newest mail) and inInbox/folder/account scoping.
This closes the disjoint-sets safety argument at the SQL-boundary level, not
just the orchestration level. Verified on the API 37 emulator (5/5).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 14:22:40 -05:00
Jason Ross adcfc7a6dc Merge pull request #52 from JMR-dev/fix/message-list-cursorwindow-overflow
fix(mailbox): project message list to avoid CursorWindow overflow
2026-07-01 13:54:36 -05:00
JMR-devandClaude Opus 4.8 f70bda77d4 fix(mailbox): project message list to avoid CursorWindow overflow
MessageDao.observeAll() ran `SELECT * FROM messages` and returned full
MessageEntity rows — including the potentially large body/isHtml columns —
for every cached message at once. Dragging big HTML bodies through SQLite's
shared ~2 MB CursorWindow overflowed it once enough bodies were cached,
crashing with "Couldn't read row N from CursorWindow" (#51).

Replace it with observeSummaries(), a body-less column projection into a new
lightweight MessageSummary POJO. The list never renders or searches the body,
and the reader already loads it lazily per-message via getById when a message
is opened, so nothing else needs it.

Add a regression test that reads back rows whose bodies exceed the window.

Closes #51

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 13:41:06 -05:00
JMR-devandClaude Opus 4.8 b3ac6d4353 fix(build): pin kotlinx-serialization to 1.8.1 for Room migration tests
AGP 9's consistent resolution shares the runtime serialization version with the
androidTest classpath, where androidx.savedstate pins it to 1.7.3. Room 2.8's
schema-bundle serializers are compiled against >= 1.8.0, so MigrationTestHelper
threw AbstractMethodError on GeneratedSerializer.typeParametersSerializers(),
failing every E2E job. Import the serialization BOM as a platform to force 1.8.1.

Verified on-device (API 37): Migration9To10Test fails unpatched, passes patched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 13:17:23 -05:00
JMR-devandClaude Opus 4.8 ac7d669133 Merge remote-tracking branch 'origin/main' into feat-screen-unlock
Brings the screen-lock app gate (#22) up to date with 25 commits of main
(signatures, backup opt-in, battery optimization, rich compose, reporting).

Conflicts resolved as a union of both features:
- SettingsRepository: adopt main's top-level Keys + shared toAppSettings()
  refactor and thread appLock through it; keep both appLock and includeInBackup
- DatabaseModule: keep provideSignatureDao; keep DatabaseFiles.NAME for DB_NAME
- MainActivity: wrap LibreMailApp(pendingCompose=...) inside AppLockGateHost
- SettingsViewModel/SettingsScreen: union app-lock and battery state/effects;
  keep LocalResources for the app-lock toast (LocalContextGetResourceValueCall lint)
- SettingsScreenTest: construct SettingsViewModel with the merged 5 args
- strings.xml: keep both the app-lock and battery/diagnostics string blocks

Fast gate green with JDK 21: assembleDebug + testDebugUnitTest + lintDebug +
compileDebugAndroidTestKotlin.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 13:17:22 -05:00
JMR-devandClaude Opus 4.8 bad597bc42 feat(sync): default fetch-all history + device-only retention (#12, #13)
Replace the fixed 50-message-per-folder header cap with a background,
resumable full-history backfill, and add a user-configurable device-only
retention limit that prunes local mail beyond it without ever deleting from
the server.

- ImapClient.fetchOlderThan pages a folder backwards in bounded batches,
  locating the boundary by binary search over message numbers (O(log n) tiny
  UID fetches, memory bounded to one batch).
- MailBackfiller + BackfillWorker page each synced folder newest→oldest,
  persisting a per-folder boundary in a new backfill_progress table so a run
  interrupted by process death / network loss resumes exactly where it stopped.
  Runs off the sync mutex, so foreground sync / pull-to-refresh stay responsive.
- MailSyncer now reconciles server deletions only within the recent UID window
  (deleteSyncedInWindowNotIn) instead of wiping everything outside the recent
  50, so backfilled history survives each foreground sync. A materialized
  messages.uid column powers the windowed reconcile and backfill boundary.
- Body/attachment prefetch still honours FetchPolicy (headers first).

- Per-account count/age overrides (nullable) with a global default; 0 = keep
  everything (the default, matching #12).
- MailPruner + PruneWorker delete local rows beyond the limit (cascading
  attachment rows + on-disk cache), never issuing a server delete. Deletes are
  chunked under SQLite's 999-parameter limit.
- Precedence with backfill: backfill pauses (does not complete) at the
  retention floor and both jobs share a maintenance mutex, so they never
  contend; foreground fetch is also capped by the count so it can't re-download
  what pruning just trimmed.
- Settings UI for the global default and per-account override, with copy making
  clear it is device-only, not the server.

Room schema v9→v10 (migration + exported schema + MigrationTestHelper test).
GreenMail tests prove the backfill caches >50 and resumes after interruption;
pruning tests cover count/age limits and never touch the server.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 13:17:22 -05:00
Jason Ross 4175b3f440 Merge pull request #50 from JMR-dev/feat/49-onboarding-battery-optimization
feat(onboarding): opt-in to unrestricted battery/background usage
2026-07-01 12:42:52 -05:00
JMR-devandClaude Opus 4.8 59c9f9d27e feat(onboarding): opt-in to unrestricted battery/background usage
Add a guided, F-Droid-safe onboarding step and an Advanced Settings recovery
row that let users move LibreMail to "Unrestricted" battery usage, so IMAP
IDLE push (IdleService) and periodic WorkManager sync aren't throttled or
killed by Doze. Deep-links to the system app-details screen rather than the
restricted REQUEST_IGNORE_BATTERY_OPTIMIZATIONS dialog, so it needs no new
permission and is safe on Play (#17) and F-Droid (#16).

- BatteryPromptDecision: pure, unit-tested gate (supported && !unrestricted && !handled)
- BatteryOptimizationManager: reads isIgnoringBatteryOptimizations, builds the deep-link intent
- Onboarding step shown after the first account is added; skipped when already
  unrestricted or already handled; re-checks status on resume
- Advanced Settings row shows current status and re-opens the system screen
- battery_prompt_handled flag persisted in the settings DataStore (kept out of AppSettings)
- Unit tests for the decision + view model; Espresso E2E for the step

Closes #49

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 12:31:54 -05:00
Jason Ross 2ce527ad77 Merge pull request #48 from JMR-dev/docs-readme-refresh
docs: refresh README for onboarding/app-password/rich-compose/opt-in features (#20)
2026-07-01 11:16:50 -05:00
JMR-dev c3287b66f4 Merge remote-tracking branch 'origin/main' into docs-readme-refresh 2026-07-01 11:06:22 -05:00
Jason Ross 9ab1765116 Merge pull request #47 from JMR-dev/feat-rich-compose
feat(compose): rich HTML editor, multipart send, and signatures (#23, #36, #37, #38)
2026-07-01 11:06:21 -05:00
JMR-devandClaude Opus 4.8 dde081c4a0 Merge branch 'main' into feat-rich-compose
Renumber the rich-composition schema change onto main's v10 (#43 bcc):
- Migrations.kt: keep MIGRATION_9_10 (bccAddresses) from main; move the rich
  changes (bodyHtml columns + signatures table) into a new MIGRATION_10_11.
- @Database version 10 -> 11; register MIGRATION_10_11; take main's 10.json and
  regenerate 11.json (now carries bccAddresses + bodyHtml + signatures).
- Union OutgoingMessage/ComposeViewModel/Routes (bcc + bodyHtml + signatures +
  reportReview routes); merge both sides' SmtpSender/ComposeViewModel tests.
- Fix MappersHtmlBodyTest positional Draft(...) broken by the inserted bcc field.
Verified: assembleDebug + testDebugUnitTest + lintDebug + ktlintCheck + detekt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 10:56:12 -05:00
Jason Ross dbe258126b Merge pull request #43 from JMR-dev/feat-mailto-default-app
feat(mailto): handle mailto: links and email share intents (#25)
2026-07-01 10:41:12 -05:00
JMR-devandClaude Opus 4.8 e395e2af1c Merge branch 'main' into feat-mailto-default-app
Resolve LibreMailApp.kt: union the compose function params so mailto prefill
(pendingCompose/onComposeHandled) coexists with onboarding start-gating
(appViewModel) and the crash dialog (startupViewModel); keep LaunchedEffect +
getValue/remember imports. Verified locally: assembleDebug + testDebugUnitTest +
lintDebug + ktlintCheck + detekt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 10:31:56 -05:00
Jason Ross d2b9d990ad Merge pull request #42 from JMR-dev/feat-debug-reporting
feat(reporting): opt-in debug reporting client (capture + review/submit) (#32, #33)
2026-07-01 10:28:45 -05:00
JMR-devandClaude Opus 4.8 291c9a2b4d Merge branch 'main' into feat-debug-reporting
Resolve conflicts from #40/#41/#44:
- build.gradle.kts: keep DEBUG_REPORT_ENDPOINT field + val, take #40's
  outlookRedirectScheme (gmailRedirectScheme was deleted).
- LibreMailApp.kt: function takes BOTH appViewModel (start-dest gating, #44)
  and startupViewModel (crash dialog, #42); use renamed AccountPickerScreen.
- SettingsScreen.kt: keep both the Diagnostics (#42) and Backup (#41) sections.
Verified locally: assembleDebug + testDebugUnitTest + lintDebug + ktlintCheck + detekt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 10:18:28 -05:00
Jason Ross f47efadfa6 Merge pull request #44 from JMR-dev/feat-onboarding-flow
feat(onboarding): first-run flow, vendor picker, and app-password setup (#26-#31)
2026-07-01 10:10:58 -05:00
JMR-dev 781966e0a0 Merge remote-tracking branch 'origin/main' into feat-onboarding-flow 2026-07-01 09:59:41 -05:00
Jason Ross 085475bf93 Merge pull request #41 from JMR-dev/feat-backup-optin
feat(backup): opt-in Android Backup for settings only (#21)
2026-07-01 09:59:39 -05:00
JMR-dev df5b99aff9 Merge remote-tracking branch 'origin/main' into feat-backup-optin 2026-07-01 09:49:24 -05:00
Jason Ross 4504d34fc6 Merge pull request #40 from JMR-dev/fix-remove-gmail-oauth
refactor(auth): remove dead Gmail OAuth code path (#39)
2026-07-01 09:41:41 -05:00
JMR-devandClaude Opus 4.8 25e1a8b83c test(onboarding): scroll app-password fields/button into view before tapping
Real cause of the API 29-36 E2E timeout (the earlier 5s->15s bump didn't help,
proving it wasn't slowness): AppPasswordSetupScreen is a scrolling Column and the
"Test and add" button sits below the fold on the short default matrix emulator, so
the positional performClick was a silent no-op -> no add -> no navigation -> the
add-another wait never resolved. It passed on API 37 only because that job uses a
taller pixel_2 AVD. performScrollTo() each field + the button before interacting,
matching the existing pattern in SettingsScreenTest. Verified compileDebugAndroid
TestKotlin + ktlintCheck on JDK 21.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 09:27:29 -05:00
JMR-devandClaude Opus 4.8 876539b514 test(onboarding): widen onboarding E2E waitForText timeout to 15s
OnboardingFlowTest passed on the API-37 job but timed out (ComposeTimeoutException
after 5000ms) across the animation-disabled API 29-36 matrix, at the single
async-gated transition: click -> viewModelScope coroutine -> addImapAccount ->
DONE -> LaunchedEffect -> navigate -> AddAnother render. The flow is correct
(green on API 37; ManualSetupScreenTest proves the add-callback path); the 5s cap
was just too tight for that compound step on slower matrix emulators. waitUntil
returns as soon as the text appears, so the happy path is unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 08:41:05 -05:00
JMR-devandClaude Opus 4.8 3ea166607c test(reporting): compile instrumented SettingsScreen test with onReportProblem
The #32/#33 change added a required onReportProblem parameter to SettingsScreen
but left the existing instrumented SettingsScreenTest calling the old signature,
so :app:compileDebugAndroidTestKotlin failed in every E2E job (the local fast
gate never compiles the androidTest variant). Pass onReportProblem = {} in the
test. Verified with :app:compileDebugAndroidTestKotlin on JDK 21.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 08:41:01 -05:00
JMR-devandClaude Opus 4.8 b643c3bb61 docs: refresh README for onboarding/app-password/rich-compose/opt-in features
Bring README in line with the post-batch shipped state (issue #20, folding in
#31's README reconciliation):

- Rewrite the status blurb and feature list to cover the onboarding flow, rich
  compose (HTML + multipart/alternative + signatures), full-history backfill
  with a device-only retention cap, opt-in app lock, mailto/default-app, and
  opt-in local debug reporting.
- Remove the Gmail OAuth setup section and the "no stored passwords for Gmail"
  claim; Gmail/Yahoo/iCloud are now app-password IMAP/SMTP vendors.
- Add an "Accounts and onboarding" section (Outlook OAuth; Gmail/Yahoo/iCloud
  app password with vendor app-password pages + Gmail 2SV note; Other IMAP/SMTP)
  and keep the Outlook OAuth setup section.
- Add a "Privacy and data flow" note: opt-in cache encryption, local
  user-initiated debug reporting (no hosted pipeline), and opt-in Android Backup.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 00:38:27 -05:00
JMR-devandClaude Opus 4.8 bee5737ec4 feat(compose): rich HTML editor, multipart send, and signatures
Bring rich composition to LibreMail (issues #36, #37, #38, and #23).

#36 HTML editor + toolbar
- New pure, JVM-testable rich-text model (`richtext/`): RichTextContent with
  inline styles + links and block markers ("• ", "N. ", "> "), serializing to a
  narrow email-safe HTML subset and back (fromHtml is a faithful inverse).
- Rich editor in ComposeScreen with a bold/italic/underline, bulleted/numbered
  list, block-quote, and link toolbar, backed by AnnotatedString. Unformatted
  text stays plaintext-only (null HTML) so it feels unchanged and is accessible.
- #23: rounded corners on the compose fields/body via MaterialTheme.shapes.

#37 multipart/alternative + reply/forward quoting
- SmtpSender builds multipart/alternative (text/plain + text/html), nested in
  multipart/mixed when there are attachments; GraphSender sends HTML content.
- HtmlToText produces a readable text/plain fallback; ReplyBuilder quotes HTML
  originals as clean blockquotes (tags stripped) without corruption.
- HTML body persists/restores through drafts and the outbox (new nullable
  bodyHtml columns; Room v9->v10 migration + schema).

#38 signatures
- New signatures table (multiple per account, one default) + repository/DAO;
  migration backfills the existing per-account signature as the default.
- Rich signatures reuse the #36 editor; a Signatures management screen (list,
  add/edit/delete, set default) is linked from per-account settings.
- The account's default signature auto-inserts on new compose / reply / forward
  (honoring the enable toggle), placed above the quote, and stays editable.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 00:30:33 -05:00
JMR-devandClaude Opus 4.8 63b49b10da feat(security): screen-lock app gate + auth-bound cache decrypt (#22)
Add an opt-in "Require screen lock" setting that gates the whole app behind
BiometricPrompt (strong biometric with device-credential fallback) and binds
the encrypted cache's SQLCipher passphrase to user authentication.

- App-lock gate: AppLockGateHost wraps the app; a pure AppLockGate state machine
  locks on cold start / resume-after-timeout and unlocks on auth.
- Auth-bound decrypt: DatabaseKeyCipher seals the DB passphrase with a Keystore
  key requiring user auth (setUserAuthenticationRequired, time-bound validity,
  setInvalidatedByBiometricEnrollment). PassphraseSession holds the unwrapped
  passphrase in memory; provideDatabase reads it only after auth.
- The non-auth master key (KeystoreCrypto) is unchanged, so background credential
  access (IDLE push) still works.
- Invalidation / lock removal: KeyInvalidationPolicy decides clear-vs-disable;
  the cache is wiped only at cold start in provideDatabase (never while Room holds
  it open) via a persisted flag + process restart, then re-synced. No corruption.
- Enabling requires a secure device lock; disabling reseals the passphrase back
  under the master key first (guarded to avoid a passphrase mismatch).

Adds androidx.biometric; MainActivity becomes a FragmentActivity (required by
BiometricPrompt). JVM tests cover the gate state machine, invalidation policy,
and passphrase session; the Keystore/BiometricPrompt/restart paths are
device-only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 00:24:37 -05:00
JMR-devandClaude Opus 4.8 e2e2c36d1d feat(onboarding): first-run flow, vendor picker, and app-password setup
Implements the onboarding epic (#26–#31) as a single feature:

- #26 First-run nav scaffold: gate the start destination on the account
  count (no accounts -> onboarding, else mailbox) via AppViewModel, holding
  render until the count is known so there is no cold-start flash. Onboarding
  is a nested nav graph with a graph-scoped OnboardingViewModel tracking the
  first account added this session. Removes NoAccountState in favour of a
  shared welcome/empty state.
- #28 Provider registry: MailProvider presets for Gmail/Yahoo/iCloud
  (IMAP+SMTP host/port/security, help URL) mirroring Account.outlook, biased
  to STARTTLS where documented; host/port/security unit-tested.
- #27 Vendor picker: AccountPickerScreen replaces the two-button setup screen
  as the single entry point (onboarding + Settings/mailbox "Add account"),
  routing Outlook -> OAuth, Gmail/Yahoo/iCloud -> app-password, Other -> manual.
- #29 App-password guided screen: one reusable screen per provider key with
  explanation + security warning + help link; verifies/persists via
  addImapAccount. ViewModel unit tests cover valid/invalid/failure paths.
- #30 "Add another?" prompt + first-account landing: after each onboarding
  add, offer Yes (back to picker) / No (open the first account's inbox,
  per-account filtered via a MAILBOX account nav arg). Only inside onboarding.
- #31 Instrumented onboarding E2E (welcome -> picker -> app-password add ->
  add-another -> first inbox); managed-device list and CI matrix already in
  lockstep. All new files carry the SPDX header.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 00:13:32 -05:00
JMR-devandClaude Opus 4.8 51c790d6bf feat(mailto): handle mailto: links and email share intents
Add intent filters so LibreMail handles mailto: (ACTION_VIEW / SENDTO)
and email SEND / SEND_MULTIPLE intents, opening a prefilled compose screen.

- MailtoParser: pure RFC 6068 parser (multiple recipients, to/cc/bcc/
  subject/body, percent-encoding; preserves a literal '+'); JVM-tested.
- IntentComposeParser: builds a ComposePrefill from a mailto: URI or the
  EXTRA_EMAIL/CC/BCC/SUBJECT/TEXT share extras.
- MainActivity parses the launch/new intent and hands a one-shot prefill to
  the NavHost (guarded against config-change duplication).
- Compose form gains a Bcc field; Routes carry cc/bcc/body deep-link args.
- bcc wired end-to-end: OutgoingMessage, SMTP + Graph senders, and outbox +
  drafts persistence via Room migration v9 -> v10.
- Multi-account send is served by the existing From picker on compose.

Default mail app: Android exposes no public RoleManager email role, so the
intent filters are what make LibreMail appear on the system "Open by default"
/ default-apps screen where the platform/OEM supports it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 00:08:06 -05:00
JMR-devandClaude Opus 4.8 d1b0593a8c feat(reporting): opt-in debug reporting client (capture + review/submit)
Implements #32 and #33: a strictly opt-in, F-Droid-safe debug reporting
client. Nothing ever leaves the device unless the user taps Submit.

#32 capture:
- CrashReporter installs a Thread.setDefaultUncaughtExceptionHandler (wired in
  LibreMailApplication) that persists a structured crash record (stack trace +
  app/version/device metadata + recent log ring buffer) locally, then delegates
  to the previous handler. Never auto-sent.
- RingLogBuffer + AppLog: a bounded in-memory, non-PII log ring buffer.
- DiagnosticsCollector assembles a minimal bundle: app version, Android/device,
  a fixed non-PII settings allow-list, and the log buffer.
- ReportStore persists pending reports as JSON files (not Room, so crash-time
  saves are robust and independent of the encrypted/migrating DB).
- "Report a problem" entry point in Settings creates a report on demand.

#33 review & submit:
- ReportReviewScreen shows the full payload verbatim (exactly what would be
  sent), a free-text comment field, and a prominent PII disclaimer, with
  explicit Submit / Discard and Copy / Save-to-file alternatives.
- Submission is user-initiated only: ReportUploadWorker (WorkManager, queue +
  retry, success/failure surfaced) POSTs to BuildConfig.DEBUG_REPORT_ENDPOINT,
  which is EMPTY by default (ingest server #34 is out of scope for this repo).
- On next launch a saved crash report is offered for review via a dialog.

Tests: 23 JVM unit tests covering crash capture + persistence (offered next
launch), diagnostic-bundle assembly (minimal, non-PII), JSON round-trip, and
the "nothing sent without Submit" invariant.

Closes #32
Closes #33

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 00:03:13 -05:00
JMR-devandClaude Opus 4.8 ffbb882227 feat(backup): opt-in Android Backup for settings only
Add an opt-in (off by default) toggle to include app data in system
Android Backup / Auto Backup, gated so only re-creatable user
preferences are ever backed up.

- Flip allowBackup to true and add LibreMailBackupAgent, which enforces
  the runtime opt-in: onFullBackup runs only when the user enables
  "Include settings in Android Backup" (default off), so no data leaves
  the device otherwise. allowBackup is a manifest flag and can't be
  toggled at runtime, hence the agent.
- Rewrite data_extraction_rules.xml (API 31+) and add backup_rules.xml
  (API 29-30) as strict allowlists that back up ONLY the
  libremail_settings DataStore. The Keystore-sealed cache passphrase
  (libremail_dbkey) and the encrypted credentials + mail-cache database
  (libremail.db) are excluded by omission; the cache re-downloads on
  next sync.
- Add includeInBackup preference + setter (nudges BackupManager on
  change) and a "Backup" settings section with F-Droid-honest copy
  (off by default, uses Google infrastructure).
- BackupPolicy is the single source of truth for eligible/excluded
  paths; unit tests cover the toggle default and assert the shipped XML
  resources include only settings and never the secrets/DB.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 23:56:18 -05:00
JMR-devandClaude Opus 4.8 657af48052 refactor(auth): remove dead Gmail OAuth code path
Gmail authenticates via app password + preconfigured IMAP/SMTP (decision
in #9), never OAuth, so the Gmail-OAuth implementation was unreachable
dead code. Remove it while keeping Outlook's AppAuth OAuth path intact.

- Delete auth/GmailAuthManager.kt (shared OAuthResult/FreshToken kept).
- Drop AuthType.OAUTH_GMAIL and its exhaustive `when` branch, the
  gmailAuthManager injection, and the SCOPE_GMAIL constant in
  MailConnectionFactory.
- Remove GMAIL_OAUTH_* BuildConfig fields, gmailOAuthClientId, and the
  gmailRedirectScheme val from app/build.gradle.kts.
- Repoint the AppAuth manifestPlaceholders["appAuthRedirectScheme"] to
  the Outlook scheme (org.libremail.outlook). AppAuth's bundled manifest
  now registers that scheme on RedirectUriReceiverActivity, so the app
  manifest's now-redundant Outlook intent-filter is removed; the
  AppCompat theme override (crash fix) is preserved. Verified the merged
  manifest.
- Drop GMAIL_OAUTH_CLIENT_ID from secrets.properties.example.

authType persists as the enum name in a TEXT column, so removing a
constant needs no Room schema change or migration.

Closes #39

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 23:44:17 -05:00
Jason Ross 64fb82ebd9 Merge pull request #8 from JMR-dev/chore/kotlin-linting
chore: Kotlin linting (ktlint + detekt), CLAUDE.md tooling, and CI enforcement
2026-06-30 22:01:37 -05:00
JMR-dev c129701590 claude settings and gradle config 2026-06-30 21:48:03 -05:00
JMR-devandClaude Opus 4.8 9ce88a881d build: pin the Gradle daemon to JDK 21
AGP 9.2 does not support JDK 25; committing the daemon-JVM criteria makes
every contributor's Gradle daemon run on JDK 21 regardless of JAVA_HOME.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 21:44:12 -05:00
JMR-devandClaude Opus 4.8 0754ad4ebf ci: enforce ktlint + detekt on pull requests
Add a `static-analysis` job (JDK 21 + Android SDK) that runs
`:app:ktlintCheck :app:detekt` and uploads the reports, and add it to the
`ci-passed` aggregating gate so lint regressions block merges like the
other checks.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 21:44:12 -05:00
JMR-devandClaude Opus 4.8 921a9a0668 chore(lint): adopt ktlint + detekt, format and fix all findings
Wire ktlint-gradle 14.2.0 and detekt 2.0.0-alpha.5 (the only detekt line
with Gradle 9 support) through the version catalog.

- .editorconfig: official Kotlin style, 120-col limit, @Composable exempt
  from function-naming.
- config/detekt/detekt.yml: slim overrides on detekt's defaults —
  @Composable exemptions for the OOP-era metrics, sane ReturnCount /
  ThrowsCount / TooManyFunctions thresholds, and TooGenericExceptionCaught
  off at the resilient network/push boundaries (which now log).

Findings fixed in code (behaviour-preserving; 81 unit tests still pass):
- SwallowedException: SendWorker / IdleService now log the caught exception.
- roleOf (Folder) and extractBody (ImapClient) split into named helpers.
- MailSyncer: hoisted a 4-condition `if` into a named val.
- TopDest extracted to its own file; ~14 magic numbers -> named constants.

The remainder is the ktlint auto-format across the module.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 21:42:52 -05:00
JMR-devandClaude Opus 4.8 c24c53ea01 chore: add CLAUDE.md, SPDX-header hook, and preflight skill
- CLAUDE.md: build gotchas (JDK 17-21, AGP built-in Kotlin, KSP-not-KAPT),
  test stack, the SPDX header rule, Conventional Commits, and commands.
- .claude/settings.json + hooks/check-spdx.py: PostToolUse hook that warns
  (non-blocking) when a .kt/.kts file lacks the SPDX license header.
- .claude/skills/preflight: runs the fast CI gate (assembleDebug +
  testDebugUnitTest + lintDebug) locally.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 21:42:31 -05:00
Jason Ross 0adf1319f6 Merge pull request #7 from JMR-dev/feat-per-account-settings
feat: per-account settings for signatures and notifications
2026-06-30 20:28:54 -05:00
JMR-devandClaude Opus 4.8 97950d0b6c fix(test): stop closing the in-memory DB under the still-active AccountSettings VM
AccountSettingsScreenTest closed its Room in-memory database in @After while
the ViewModel's `settings` Flow (kept alive by stateIn/WhileSubscribed) was
still querying it. That race surfaced as "connection pool has been closed"
(API 29) and "attempt to re-open an already-closed object" (API 36) on the
slower CI legs, while passing on 30-35/37 and locally.

Leave the in-memory DB unclosed (reclaimed with the test process) so the
lingering flow never hits a closed connection.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 20:20:30 -05:00
JMR-devandClaude Opus 4.8 40290660ae feat: per-account settings for signatures and notifications
Add a per-account settings area (reached by tapping an account in
Settings), starting with signatures and notifications.

- Storage: new Room `account_settings` table (schema v9, MIGRATION_8_9)
  with a cascading foreign key to `accounts`; AccountSettings model and
  AccountSettingsRepository (a missing row resolves to defaults).
- Signatures: plain-text per-account signature (RFC 3676 "-- "
  delimiter) auto-inserted below new messages and reply/forward drafts,
  and swapped when the From-account changes.
- Notifications: one notification channel + channel group per account so
  Android manages sound/vibration/importance (deep-linked from the app)
  and the shade bundles per account, plus an in-app per-account on/off
  gate. minSdk 29 >= API 26, so channels are always available (no
  pre-channel fallback needed).
- UI: per-account settings screen (signature field/toggle, notification
  toggle, system deep-link, remove account); shared settings components.

Verified: JVM unit tests, lintVitalRelease (NewApi), and the full
instrumented suite (30/30) on the API 37 emulator.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 20:05:37 -05:00
Jason Ross 3bcbf15e14 Merge pull request #6 from JMR-dev/fix-dark-mode-colors
Fix dark-mode reader readability + make API 37 E2E required
2026-06-30 18:35:08 -05:00
Jason Ross 9083042f2f Merge branch 'main' into fix-dark-mode-colors 2026-06-30 18:26:01 -05:00
JMR-devandClaude Opus 4.8 7987333149 ci: require the API 37 preview E2E job to merge
The custom-provisioned API 37 preview emulator has been stable, so fold its E2E
job into the aggregating "CI passed" gate's needs. Because branch protection
requires only that single check, no settings change is needed.

Drop the now-inaccurate "non-blocking" wording from the job name and comments.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 18:17:15 -05:00
JMR-devandClaude Opus 4.8 8e29aebc2e fix: render reader emails readably in dark mode
The reader rendered HTML emails as black-on-black in dark mode: the WebView
background was transparent (so the near-black app surface showed through) and the
injected CSS set no text or background color, so the WebView fell back to its
default black text.

Wrap each email with explicit, theme-derived background, text, and link colors
(surface / onSurface / primary) plus a matching color-scheme, set the WebView
background to the surface color, and allow WebView algorithmic darkening where
supported as a backstop for emails that hardcode their own foreground colors.

Add JVM contrast guards: HtmlBodyTest pins the wrapper's readability contract
(explicit colors meeting WCAG AA), and ColorSchemeContrastTest audits the
fallback light/dark Material schemes' role pairs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 18:17:06 -05:00
Jason Ross b44e0a7887 Merge pull request #5 from JMR-dev/feat-long-press-select
feat: long-press multi-select, aggressive prefetch, and download indicators
2026-06-30 17:29:53 -05:00
JMR-devandClaude Opus 4.8 96111b40df feat: long-press multi-select, aggressive prefetch, and download indicators
Add a long-press contextual action bar to the mailbox: Archive, Delete,
Spam, Move, Select All, and (single-selection only) Reply, Reply All, and
Forward. Reply All/Spam/Delete are confirmed first; deleting from Trash or
Spam warns that it is permanent.

- Delete moves to Trash and Spam moves to the Spam folder; only deletes
  already in Trash/Spam do a permanent IMAP expunge. Archive/Spam/Move
  resolve the destination per account so the unified inbox works.
- Reply/Reply All/Forward force a fresh server fetch of the original
  (recipients + body via BODY.PEEK), build a quoted draft, and open compose;
  a spinner shows during the fetch and they error via snackbar if offline.

Add a top-level "Message downloading" setting (Always fetch all / Fetch all
on Wi-Fi / Always on-demand; default Always) that aggressively pre-caches
full bodies and all attachment bytes during sync (outside the sync lock,
without marking mail read), into a persistent per-part attachment cache so
messages and attachments open instantly and offline.

Show an "available offline" mark on cached list rows and a per-attachment
"downloaded" check in the reader.

Extract a Syncer interface (MailSyncer implements it) so the mailbox can be
driven end-to-end in tests.

Tests: 66 unit + 27 instrumented, all passing on the API 37 emulator.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 17:20:22 -05:00
Jason Ross d090f721e6 Merge pull request #4 from JMR-dev/fix/e2e-preview
ci: fix hung API 37 preview emulator job
2026-06-30 13:17:18 -05:00
JMR-devandClaude Opus 4.8 fe0593dba1 ci: pin ANDROID_AVD_HOME so the API 37 preview emulator finds its AVD
The preview emulator failed every boot with "Unknown AVD name [api37]" and
exited immediately (no device -> the bounded wait timed out). avdmanager had
created the AVD under $ANDROID_SDK_HOME/.android/avd while the emulator searched
$ANDROID_SDK_HOME/avd and $HOME/.android/avd. Pin ANDROID_AVD_HOME to one path
both tools use, carry it to the boot step via $GITHUB_ENV, and list AVDs after
creation to verify.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 12:59:35 -05:00
JMR-devandClaude Opus 4.8 b8086f89df ci: make API 37 preview emulator boot fail-fast and diagnosable
The custom-provisioned preview job hung in 'Boot emulator and run E2E' until
the 35-min cap: adb wait-for-device had no timeout and the emulator's own
output was never captured, so a failed boot was both invisible and unbounded.
Bound the wait with a single 300s timeout covering device registration + full
boot, retry once, capture the emulator log, and dump it (plus logcat) on
failure.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 12:45:41 -05:00
Jason Ross e175ac13be Merge pull request #3 from JMR-dev/feat-folder-view
Add IMAP folder navigation drawer + per-API E2E CI matrix
2026-06-30 12:36:30 -05:00
JMR-devandClaude Opus 4.8 957da0c46f ci: run debug-build and unit-tests on x86_64
Linux-arm64 runners can't set up the SDK here: android-actions/setup-android's
sdkmanager fails (exit 1) on the android-37.0 preview platform, and the emulator
package has no arm64-Linux build. These are pure build/JVM-unit jobs whose
results are host-arch-independent, so x86_64 loses no device coverage (real
arm64 ABI coverage would require arm64 emulators, i.e. macOS hosts).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 12:28:55 -05:00
JMR-devandClaude Opus 4.8 13a32df873 Add IMAP folder navigation drawer + per-API E2E CI matrix
Folder view: a left navigation drawer lists each account's IMAP folders;
tapping one browses and caches that folder's mail. IMAP UIDs are unique only
within a folder, so message identity, the fetch/read/flag/delete paths, sync,
and the Room cache all became folder-aware (id = "accountId:folder:uid"; new
`folder` column; schema v7->v8). Standard folders (Inbox/Sent/Drafts/Spam/Trash/
Archive) surface with friendly names + icons via RFC 6154 SPECIAL-USE attributes
with a case-insensitive name fallback; the multi-account drawer adds an account
switcher and a unified "All Inboxes". INBOX stays the only auto-synced,
IDLE-watched, notifying folder; other folders sync on demand.

Lower minSdk 33 -> 29 for a rolling ~7-year Android support window; guard the
API-33 POST_NOTIFICATIONS runtime request accordingly.

Tests and CI:
- Bump espresso-core 3.6.1 -> 3.7.0 so Compose UI tests run on API 37
  (3.6.1's InputManagerEventInjectionStrategy reflects a removed hidden method).
- New coverage across layers: FolderRoleTest, ImapClientTest folder cases,
  MailboxViewModelTest, MailRepositoryImplTest folder routing, a FolderDrawer
  Compose UI test, and LibreMailDatabaseTest folder DAO/reconcile tests.
- Gradle Managed Devices + a CI E2E matrix over every API 29-36; a single
  "CI passed" gate job fans in all jobs and is required by branch protection.
- Non-blocking, custom-provisioned API 37 (preview) E2E job with image caching.
- Build + unit-test jobs run on arm64 (ubuntu-24.04-arm); emulators stay x86_64.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 12:23:00 -05:00
Jason Ross 7e427e1295 Merge pull request #2 from JMR-dev/fix-outlook-login-flow
Fix Outlook sign-in crash and complete the OAuth login flow
2026-06-29 23:26:09 -05:00
JMR-devandClaude Opus 4.8 317ce24054 Fix Outlook sign-in crash and complete the OAuth login flow
"Sign in with Microsoft" crashed on the redirect back from the browser and
never completed a login. Verified end-to-end on a real Outlook account after
three fixes, in flow order:

- Redirect crash (the reported symptom): AppAuth's RedirectUriReceiverActivity
  extends AppCompatActivity, so it needs a Theme.AppCompat theme. This app is
  pure Compose (framework Theme.Material), and AppAuth declares that activity
  with no theme of its own, so it inherited the Material app theme and threw
  "You need to use a Theme.AppCompat theme" the instant Android launched it to
  deliver the redirect. Give it the translucent AppCompat theme AppAuth itself
  applies to AuthorizationManagementActivity. (Not an R8 issue.)

- Token exchange rejected with AADSTS70011 ("must include a 'scope' input
  parameter"): one consent spans two Microsoft resources (Graph for send,
  Exchange Online for IMAP), so Microsoft mints one token per resource and the
  code-to-token exchange must name a single resource. AppAuth's
  createTokenExchangeRequest() sends no scope; build the request explicitly
  with a single-resource scope.

- "Invalid ID Token" / nonce mismatch: the hand-built exchange request must
  replicate every field createTokenExchangeRequest() sets, including the nonce
  AppAuth validates the id_token against (and the PKCE code verifier).

Also harden the account-setup screen: guard the previously unguarded
createAuthIntent() launch (AppAuth throws ActivityNotFoundException when no
browser is available) so it surfaces an error instead of crashing, dispose the
AuthorizationService that createAuthIntent() leaked, and log sign-in failures
via Log.d (stripped from release builds by the existing ProGuard rule).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 23:15:14 -05:00
332 changed files with 40673 additions and 1620 deletions
+61
View File
@@ -0,0 +1,61 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: GPL-3.0-or-later
"""PostToolUse (Write|Edit) hook: warn when a Kotlin source file is missing the
SPDX license header that every LibreMail source file must carry.
Non-blocking: prints a JSON warning (systemMessage + additionalContext so Claude
adds the header) and always exits 0. Any error is swallowed so the tool flow is
never broken by this check.
"""
import json
import sys
REQUIRED = "SPDX-License-Identifier"
HEADER_LINE = "// SPDX-License-Identifier: GPL-3.0-or-later"
def main() -> int:
try:
data = json.load(sys.stdin)
except Exception:
return 0
path = (data.get("tool_response") or {}).get("filePath") \
or (data.get("tool_input") or {}).get("file_path")
if not path:
return 0
lower = path.lower()
if not (lower.endswith(".kt") or lower.endswith(".kts")):
return 0
try:
with open(path, "r", encoding="utf-8", errors="replace") as fh:
content = fh.read()
except OSError:
# File missing (e.g. a delete) or unreadable — nothing to check.
return 0
if REQUIRED in content:
return 0
msg = f"SPDX header missing in {path}"
out = {
"systemMessage": msg,
"hookSpecificOutput": {
"hookEventName": "PostToolUse",
"additionalContext": (
f'{path} is missing the license header. Add "{HEADER_LINE}" as the '
"first line — every LibreMail source file carries it."
),
},
}
print(json.dumps(out))
return 0
if __name__ == "__main__":
try:
sys.exit(main())
except Exception:
sys.exit(0)
+21
View File
@@ -0,0 +1,21 @@
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "python .claude/hooks/check-spdx.py",
"timeout": 30,
"statusMessage": "Checking SPDX header"
}
]
}
]
},
"enabledPlugins": {
"skill-creator@claude-plugins-official": true,
"frontend-design@claude-plugins-official": true
}
}
+43
View File
@@ -0,0 +1,43 @@
---
name: preflight
description: Run LibreMail's fast CI gate locally (assembleDebug + testDebugUnitTest + lintDebug + ktlintCheck + detekt) before pushing or opening a PR. Mirrors the merge gate; does NOT run emulator E2E. Use before treating a change as done.
---
# /preflight
Run the same fast checks CI enforces on every PR, in order, and report the outcome.
## Preconditions
- The Gradle daemon must run on **JDK 17–21**. AGP 9.2 fails on JDK 25+. If a build errors
with a JDK/AGP version mismatch, check `java -version` / `JAVA_HOME` and point it at a 17–21
JDK (e.g. Android Studio's bundled JBR) before retrying.
- PowerShell: invoke the wrapper as `.\gradlew`. Git Bash / the Bash tool: `./gradlew`.
## Steps
Run these, stopping at the first failure:
```bash
./gradlew :app:assembleDebug
./gradlew :app:testDebugUnitTest
./gradlew :app:lintDebug
./gradlew :app:ktlintCheck :app:detekt
```
`ktlintCheck` + `detekt` are exactly what CI's **Static analysis** job runs — they cover the
`test`/`androidTest` source sets that `lintDebug` does not, so a style violation there fails the
merge gate even when the build and lint are green. Add `--continue` to any command (e.g.
`:app:ktlintCheck :app:detekt --continue`) to collect every failure in one pass instead of
stopping at the first.
## Reporting
- If everything passes, say so plainly (e.g. "preflight green: build, unit tests, lint, ktlint, detekt").
- On failure, surface the actual Gradle error and point at the relevant report:
- unit tests → `app/build/reports/tests/testDebugUnitTest/`
- lint → `app/build/reports/lint-results-debug.html`
- ktlint → `app/build/reports/ktlint/` (per source set, e.g. `ktlintTestSourceSetCheck/`)
- detekt → `app/build/reports/detekt/`
- Do **not** run emulator/E2E (`connectedDebugAndroidTest`) here — that's CI's job unless the
user explicitly asks.
+7
View File
@@ -0,0 +1,7 @@
root = true
[*.{kt,kts}]
ktlint_code_style = intellij_idea
max_line_length = 120
# @Composable functions are PascalCase by convention — don't flag them as bad function names.
ktlint_function_naming_ignore_when_annotated_with = Composable
+2
View File
@@ -9,6 +9,8 @@ gradlew text eol=lf
# Treat these as binary.
*.jar binary
*.ttf binary
*.otf binary
*.png binary
*.jpg binary
*.jpeg binary
+40
View File
@@ -0,0 +1,40 @@
# SPDX-License-Identifier: GPL-3.0-or-later
name: Auto-update PR branches
# When main advances, rebase any auto-merge-armed PR that has fallen behind, so the
# "require branches up to date" branch rule doesn't need manual branch updates. Only PRs
# with GitHub auto-merge enabled are touched (PR_FILTER: auto_merge) — held/draft PRs are
# left alone.
#
# IMPORTANT: for the branch update to RE-TRIGGER the PR's CI (so it can pass and merge),
# this must run with a PAT, not the default GITHUB_TOKEN — pushes made by GITHUB_TOKEN do
# not start new workflow runs (GitHub's anti-recursion rule), so the updated PR would sit
# with stale checks. Create a fine-grained PAT scoped to this repo with
# contents:read/write + pull-requests:read/write and add it as the AUTOUPDATE_TOKEN secret.
# Without it this falls back to GITHUB_TOKEN, which updates the branch but will NOT re-run
# the PR's checks.
on:
push:
branches: [main]
permissions:
contents: write
pull-requests: write
concurrency:
group: autoupdate-${{ github.ref }}
cancel-in-progress: true
jobs:
autoupdate:
name: Auto-update armed PRs
runs-on: ubuntu-latest
environment: CI_CD
steps:
- name: Update behind PRs that have auto-merge enabled
uses: chinthakagodawita/autoupdate@0707656cd062a3b0cf8fa9b2cda1d1404d74437e # v1.7.0
env:
GITHUB_TOKEN: ${{ secrets.AUTOUPDATE_TOKEN || secrets.GITHUB_TOKEN }}
PR_FILTER: "auto_merge"
MERGE_CONFLICT_ACTION: "ignore"
+188 -9
View File
@@ -22,6 +22,10 @@ env:
jobs:
debug-build:
name: Debug build
# x86_64: Linux-arm64 runners can't set up this SDK — android-actions/setup-android's sdkmanager
# fails (exit 1) on the android-37.0 preview platform, and the emulator package has no arm64-Linux
# build. Build/unit-test results are host-arch-independent anyway (R8/AGP/JVM); real arm64
# device-ABI coverage would need arm64 emulators, which require macOS hosts.
runs-on: ubuntu-latest
steps:
- name: Check out source
@@ -85,16 +89,58 @@ jobs:
path: app/build/reports/tests/testDebugUnitTest/
if-no-files-found: warn
instrumented-tests:
name: Instrumented / UI tests (emulator)
static-analysis:
name: Static analysis
runs-on: ubuntu-latest
steps:
- name: Check out source
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Set up JDK 21
uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0
with:
distribution: temurin
java-version: "21"
# AGP configuration needs the SDK even for ktlint/detekt (they run on the :app module).
- name: Set up Android SDK
uses: android-actions/setup-android@40fd30fb8d7440372e1316f5d1809ec01dcd3699 # v4.0.1
- name: Install SDK platform and build-tools
run: sdkmanager "$ANDROID_PLATFORM" "$ANDROID_BUILD_TOOLS"
- name: Set up Gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
# --continue so a ktlint failure still lets detekt report (and vice versa).
- name: Run ktlint and detekt
run: ./gradlew :app:ktlintCheck :app:detekt --continue --stacktrace
- name: Upload analysis reports
if: ${{ !cancelled() }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: static-analysis-reports
path: |
app/build/reports/ktlint/
app/build/reports/detekt/
if-no-files-found: warn
e2e:
name: E2E
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
# minSdk is 33; the instrumented suite (SQLCipher, Keystore, Room, Compose UI,
# mail providers) needs no API-37-specific behavior, so it runs on a stable,
# widely-available emulator image rather than the bleeding-edge android-37.0.
api-level: [35]
# Every Android API level across the rolling ~7-year support window: minSdk (29 / Android 10,
# 2019) through the latest stable. Each level boots its own emulator and runs the full
# instrumented + Compose UI (E2E) suite; all of them fan in to the "CI passed" gate. When a
# new Android ships, add it and drop the oldest level that has aged out of ~7 years. API 37
# (preview) is NOT in this matrix because emulator-runner can't provision its nonstandard
# android-37.0 / google_apis_ps16k image (it would wedge the gate) — it's covered separately
# by the custom-provisioned `e2e-preview` job below. Keep in sync with
# testOptions.managedDevices in app/build.gradle.kts.
api-level: [29, 30, 31, 32, 33, 34, 35, 36]
steps:
- name: Check out source
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
@@ -144,7 +190,7 @@ jobs:
disable-animations: false
script: echo "Generated AVD snapshot for caching."
- name: Run instrumented tests
- name: Run E2E tests
uses: reactivecircus/android-emulator-runner@e89f39f1abbbd05b1113a29cf4db69e7540cae5a # v2.37.0
with:
api-level: ${{ matrix.api-level }}
@@ -155,10 +201,143 @@ jobs:
disable-animations: true
script: ./gradlew connectedDebugAndroidTest --stacktrace
- name: Upload instrumented test report
- name: Upload E2E test report
if: ${{ !cancelled() }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: instrumented-test-report-api${{ matrix.api-level }}
name: e2e-test-report-api${{ matrix.api-level }}
path: app/build/reports/androidTests/connected/
if-no-files-found: warn
# API 37 (Android 17, preview) E2E. Its only system image is the nonstandard
# android-37.0 / google_apis_ps16k (16 KB page size), which reactivecircus/android-emulator-runner
# can't provision (it builds android-37 / google_apis, neither of which exists), so this job
# CUSTOM-PROVISIONS the emulator with sdkmanager/avdmanager/emulator directly. It is REQUIRED:
# part of the "CI passed" gate's needs (the preview emulator has proven stable in practice), so a
# genuine failure blocks merges. When a stable, emulator-runner-friendly API 37 image ships, fold
# 37 into the main `e2e` matrix and delete this job.
e2e-preview:
name: E2E (API 37 preview)
runs-on: ubuntu-latest
timeout-minutes: 35
env:
API37_IMAGE: "system-images;android-37.0;google_apis_ps16k;x86_64"
steps:
- name: Check out source
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Set up JDK 21
uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0
with:
distribution: temurin
java-version: "21"
- name: Set up Android SDK
uses: android-actions/setup-android@40fd30fb8d7440372e1316f5d1809ec01dcd3699 # v4.0.1
- name: Set up Gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
# The hardware-accelerated emulator needs KVM, which is gated behind a udev rule.
- name: Enable KVM
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm
# Cache the ~1 GB preview system image so only the first run pays the download.
- name: Cache API 37 system image
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
# GitHub-hosted ubuntu runners install the SDK at /usr/local/lib/android/sdk; caching the
# image dir (with its package metadata) lets sdkmanager treat it as installed and skip the
# re-download on a cache hit.
path: /usr/local/lib/android/sdk/system-images/android-37.0
key: sysimg-android-37.0-google_apis_ps16k-x86_64
- name: Install SDK packages + preview system image
run: sdkmanager "$ANDROID_PLATFORM" "$ANDROID_BUILD_TOOLS" "platform-tools" "emulator" "$API37_IMAGE"
- name: Create API 37 AVD
run: |
# avdmanager and the emulator disagree on the default AVD dir when ANDROID_SDK_HOME is set
# on the runner (avdmanager writes $ANDROID_SDK_HOME/.android/avd; the emulator looks in
# $ANDROID_SDK_HOME/avd), which made the boot step report "Unknown AVD name [api37]". Pin
# ANDROID_AVD_HOME so both agree, and carry it to the boot step via $GITHUB_ENV.
export ANDROID_AVD_HOME="$HOME/.android/avd"
echo "ANDROID_AVD_HOME=$ANDROID_AVD_HOME" >> "$GITHUB_ENV"
mkdir -p "$ANDROID_AVD_HOME"
echo "no" | avdmanager create avd -n api37 -k "$API37_IMAGE" -d pixel_2 --force
echo "AVDs visible to the emulator:"; "$ANDROID_SDK_ROOT/emulator/emulator" -list-avds
- name: Boot emulator and run E2E
run: |
set -euo pipefail
EMU_LOG="${RUNNER_TEMP:-/tmp}/emulator.log"
boot_emulator() {
echo "::group::Start API 37 emulator (attempt $1)"
# Capture the emulator's own output — without this a boot failure is invisible.
"$ANDROID_SDK_ROOT/emulator/emulator" -avd api37 \
-no-window -no-audio -no-boot-anim -no-snapshot -accel on \
-gpu swiftshader_indirect -camera-back none -camera-front none > "$EMU_LOG" 2>&1 &
# ONE bounded wait covering both device registration and full boot, so a stuck emulator
# fails fast instead of hanging the whole job until the 35-min cap (the original bug).
if timeout 300 adb wait-for-device shell \
'while [ "$(getprop sys.boot_completed | tr -d "\r")" != "1" ]; do sleep 2; done'; then
echo "::endgroup::"; return 0
fi
echo "::endgroup::"
echo "::warning::API 37 emulator did not boot within 300s (attempt $1)"
adb devices || true
echo "--- emulator.log (tail) ---"; tail -120 "$EMU_LOG" || true
adb emu kill 2>/dev/null || true
sleep 5
return 1
}
booted=0
for attempt in 1 2; do boot_emulator "$attempt" && { booted=1; break; }; done
[ "$booted" = "1" ] || { echo "::error::API 37 preview emulator failed to boot after 2 attempts"; exit 1; }
adb shell input keyevent 82 || true
./gradlew connectedDebugAndroidTest --stacktrace
- name: Dump emulator log on failure
if: failure()
run: |
echo "--- emulator.log ---"; tail -200 "${RUNNER_TEMP:-/tmp}/emulator.log" 2>/dev/null || echo "(none)"
echo "--- logcat ---"; adb logcat -d 2>/dev/null | tail -120 || echo "(device unavailable)"
- name: Shut down emulator
if: always()
run: adb emu kill || true
- name: Upload E2E (API 37) report
if: ${{ !cancelled() }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: e2e-test-report-api37-preview
path: app/build/reports/androidTests/connected/
if-no-files-found: warn
# Single aggregating gate so branch protection can require ALL CI jobs with one stable status
# check. It depends on every job — including each api-level of the E2E matrix — so adding/removing
# a matrix level needs no change to branch protection (the per-"(api-level)" check names would
# otherwise have to be re-listed each time).
ci-passed:
name: CI passed
if: always()
needs: [static-analysis, debug-build, unit-tests, e2e, e2e-preview]
runs-on: ubuntu-latest
steps:
- name: Verify every required job succeeded
if: ${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') }}
run: |
echo "Required CI jobs did not all succeed:"
echo " static-analysis: ${{ needs.static-analysis.result }}"
echo " debug-build: ${{ needs.debug-build.result }}"
echo " unit-tests: ${{ needs.unit-tests.result }}"
echo " e2e: ${{ needs.e2e.result }}"
echo " e2e-preview: ${{ needs.e2e-preview.result }}"
exit 1
+492 -29
View File
@@ -1,34 +1,82 @@
# SPDX-License-Identifier: GPL-3.0-or-later
#
# Release pipeline (issue #19): tag → fast CI gate → build + sign → GitHub release,
# Google Play, Galaxy Store (stub), S3 archive. Full docs: docs/release.md.
#
# Every publish/archive stage is gated on its CI secrets and SKIPS with a clear log
# message when they are absent, so the workflow runs end-to-end today (before the
# store accounts from #16/#17/#18 exist). No secret ever lives in the repo.
#
# Secrets (all optional; configure in Settings → Secrets and variables → Actions):
# Signing RELEASE_KEYSTORE_BASE64, RELEASE_KEYSTORE_PASSWORD,
# RELEASE_KEY_ALIAS, RELEASE_KEY_PASSWORD
# → absent: artifacts are built with the debug-key fallback and named
# *-unsigned (installable for testing, NOT publishable).
# Play PLAY_SERVICE_ACCOUNT_JSON (also requires signing secrets)
# Galaxy GALAXY_SERVICE_ACCOUNT_ID, GALAXY_PRIVATE_KEY, GALAXY_CONTENT_ID
# (reserved — automated submission is stubbed; see docs/release.md#galaxy-store)
# Archive ARCHIVE_S3_BUCKET, ARCHIVE_S3_ACCESS_KEY_ID, ARCHIVE_S3_SECRET_ACCESS_KEY,
# ARCHIVE_S3_ENDPOINT (optional, for non-AWS), ARCHIVE_S3_REGION (optional)
#
# F-Droid needs no job here: it builds signed packages itself from the pushed tag and
# the repo's fastlane metadata (issue #18).
name: Release
on:
# The normal release path: push an annotated tag like v0.2.0.
push:
tags: ["v*"]
# Manual path: rehearse the pipeline (dry run) or re-run publication for an existing tag.
workflow_dispatch:
inputs:
tag:
description: "Release tag to create (e.g. v0.1.0)"
required: true
description: "Existing tag to (re-)release, e.g. v0.2.0. Leave empty to rehearse against the current branch head (build only)."
required: false
type: string
prerelease:
description: "Mark this GitHub release as a pre-release"
dry_run:
description: "Dry run: build, sign and checksum only — skip GitHub release, store publication and archiving."
required: false
type: boolean
default: true
play_track:
description: "Google Play track to publish to."
required: false
type: choice
options: [internal, alpha, beta, production]
default: internal
play_rollout_fraction:
description: "Staged-rollout user fraction for the production track (0 < f < 1, e.g. 0.10), or 1.0 for a full rollout. Ignored on other tracks."
required: false
type: string
default: "0.10"
# Never cancel a half-finished publication; queue instead.
concurrency:
group: release-${{ inputs.tag || github.ref }}
cancel-in-progress: false
# Needed to create the tag, the GitHub release, and upload its assets.
permissions:
contents: write
contents: read
env:
# Keep in sync with .github/workflows/ci.yml.
ANDROID_PLATFORM: "platforms;android-37.0"
ANDROID_BUILD_TOOLS: "build-tools;37.0.0"
jobs:
release:
name: Build APK and publish release
# Mirrors ci.yml's fast jobs (assembleDebug / testDebugUnitTest / static analysis, plus
# lintDebug) as the release prerequisite required by issue #19. ci.yml only runs on pull
# requests, so a tag push gets no other gate. The emulator E2E matrix is deliberately NOT
# duplicated here — it already gated every PR that reached the tagged commit.
fast-gate:
name: Fast CI gate
runs-on: ubuntu-latest
timeout-minutes: 40
steps:
- name: Check out source
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ inputs.tag || github.ref }}
- name: Set up JDK 21
uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0
@@ -45,33 +93,448 @@ jobs:
- name: Set up Gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
# No release keystore is configured in CI, so the build falls back to the debug
# signing key (installable for testing, not for store publication).
- name: Assemble release APK
run: ./gradlew assembleRelease --stacktrace
- name: Assemble, unit-test, lint, static analysis
run: ./gradlew :app:assembleDebug :app:testDebugUnitTest :app:lintDebug :app:ktlintCheck :app:detekt --stacktrace
- name: Stage release artifacts (APK + source archives)
build:
name: Build and sign release artifacts
needs: [fast-gate]
runs-on: ubuntu-latest
timeout-minutes: 40
outputs:
release_tag: ${{ steps.plan.outputs.release_tag }}
version: ${{ steps.plan.outputs.version }}
publish: ${{ steps.plan.outputs.publish }}
signed: ${{ steps.plan.outputs.signed }}
prerelease: ${{ steps.plan.outputs.prerelease }}
have_play: ${{ steps.plan.outputs.have_play }}
have_galaxy: ${{ steps.plan.outputs.have_galaxy }}
have_s3: ${{ steps.plan.outputs.have_s3 }}
steps:
- name: Check out source (full history for the changelog)
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ inputs.tag || github.ref }}
fetch-depth: 0
# `if:` cannot read the secrets context, so hoist secret *presence* into env here and
# expose the resulting gates as job outputs that downstream jobs test in their `if:`.
- name: Plan release (tag, signing, publish gates)
id: plan
env:
EVENT_NAME: ${{ github.event_name }}
INPUT_TAG: ${{ inputs.tag }}
INPUT_DRY_RUN: ${{ inputs.dry_run }}
HAVE_SIGNING: ${{ secrets.RELEASE_KEYSTORE_BASE64 != '' && secrets.RELEASE_KEYSTORE_PASSWORD != '' && secrets.RELEASE_KEY_ALIAS != '' && secrets.RELEASE_KEY_PASSWORD != '' }}
PARTIAL_SIGNING: ${{ secrets.RELEASE_KEYSTORE_BASE64 != '' || secrets.RELEASE_KEYSTORE_PASSWORD != '' || secrets.RELEASE_KEY_ALIAS != '' || secrets.RELEASE_KEY_PASSWORD != '' }}
HAVE_PLAY: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON != '' }}
HAVE_GALAXY: ${{ secrets.GALAXY_SERVICE_ACCOUNT_ID != '' && secrets.GALAXY_PRIVATE_KEY != '' && secrets.GALAXY_CONTENT_ID != '' }}
HAVE_S3: ${{ secrets.ARCHIVE_S3_BUCKET != '' && secrets.ARCHIVE_S3_ACCESS_KEY_ID != '' && secrets.ARCHIVE_S3_SECRET_ACCESS_KEY != '' }}
run: |
set -euo pipefail
tag='${{ inputs.tag }}'
mkdir -p dist
apk="$(find app/build/outputs/apk/release -name '*.apk' -print -quit)"
cp "$apk" "dist/LibreMail-${tag}.apk"
# Source archives contain only git-tracked files at the released commit.
git archive --format=zip --prefix="LibreMail-${tag}/" -o "dist/LibreMail-${tag}-src.zip" HEAD
git archive --format=tar --prefix="LibreMail-${tag}/" HEAD | gzip > "dist/LibreMail-${tag}-src.tar.gz"
ls -l dist
- name: Create GitHub release
tag=""
if [ "$EVENT_NAME" = "push" ]; then
tag="$GITHUB_REF_NAME"
else
tag="$INPUT_TAG"
fi
publish=false
if [ -n "$tag" ] && [ "$INPUT_DRY_RUN" != "true" ]; then
publish=true
fi
if [ "$EVENT_NAME" = "workflow_dispatch" ] && [ -z "$tag" ]; then
echo "::notice::No tag input — rehearsal build only (no GitHub release, store publication or archiving)."
elif [ "$publish" != "true" ]; then
echo "::notice::Dry run — building and checksumming only; publication and archiving are skipped."
fi
version="${tag:-dev-$(git rev-parse --short HEAD)}"
prerelease=false
if [ "$HAVE_SIGNING" != "true" ]; then
prerelease=true
echo "::notice::Release signing secrets not configured — artifacts fall back to the debug key and are named *-unsigned (NOT store-publishable). See docs/release.md#release-signing."
if [ "$PARTIAL_SIGNING" = "true" ]; then
echo "::warning::Only some of the four RELEASE_* signing secrets are set; all four are required. Building unsigned."
fi
fi
case "$tag" in *-*) prerelease=true ;; esac
if [ "$HAVE_PLAY" != "true" ]; then
echo "::notice::Google Play publication will be skipped: secret PLAY_SERVICE_ACCOUNT_JSON is not configured. See docs/release.md#google-play."
elif [ "$HAVE_SIGNING" != "true" ]; then
echo "::warning::Google Play publication will be skipped: Play credentials are configured but the artifacts are unsigned (missing RELEASE_* signing secrets)."
fi
if [ "$HAVE_GALAXY" != "true" ]; then
echo "::notice::Galaxy Store credentials not configured — the Galaxy job only prints the manual publication path. See docs/release.md#galaxy-store."
fi
if [ "$HAVE_S3" != "true" ]; then
echo "::notice::S3 archiving will be skipped: ARCHIVE_S3_* secrets are not configured. See docs/release.md#binary-archive-s3."
fi
{
echo "release_tag=$tag"
echo "version=$version"
echo "publish=$publish"
echo "signed=$HAVE_SIGNING"
echo "prerelease=$prerelease"
echo "have_play=$HAVE_PLAY"
echo "have_galaxy=$HAVE_GALAXY"
echo "have_s3=$HAVE_S3"
} >> "$GITHUB_OUTPUT"
- name: Warn when the tag and versionName disagree
if: steps.plan.outputs.release_tag != ''
env:
RELEASE_TAG: ${{ steps.plan.outputs.release_tag }}
run: |
set -euo pipefail
version_name="$(sed -n 's/^[[:space:]]*versionName = "\([^"]*\)".*/\1/p' app/build.gradle.kts | head -1)"
expected="${RELEASE_TAG#v}"
if [ "$version_name" != "$expected" ]; then
echo "::warning::Tag $RELEASE_TAG does not match versionName '$version_name' in app/build.gradle.kts — did you forget to bump versionCode/versionName before tagging? (docs/release.md#cutting-a-release)"
fi
# Reconstructs the git-ignored secrets.properties that app/build.gradle.kts already
# reads for release signing, and materialises the keystore from the base64 secret.
# Both live only on the ephemeral runner; nothing is written back to the repo.
- name: Configure release signing from CI secrets
if: steps.plan.outputs.signed == 'true'
env:
RELEASE_KEYSTORE_BASE64: ${{ secrets.RELEASE_KEYSTORE_BASE64 }}
RELEASE_KEYSTORE_PASSWORD: ${{ secrets.RELEASE_KEYSTORE_PASSWORD }}
RELEASE_KEY_ALIAS: ${{ secrets.RELEASE_KEY_ALIAS }}
RELEASE_KEY_PASSWORD: ${{ secrets.RELEASE_KEY_PASSWORD }}
run: |
set -euo pipefail
keystore="$RUNNER_TEMP/release.keystore"
printf '%s' "$RELEASE_KEYSTORE_BASE64" | base64 -d > "$keystore"
{
printf 'RELEASE_STORE_FILE=%s\n' "$keystore"
printf 'RELEASE_STORE_PASSWORD=%s\n' "$RELEASE_KEYSTORE_PASSWORD"
printf 'RELEASE_KEY_ALIAS=%s\n' "$RELEASE_KEY_ALIAS"
printf 'RELEASE_KEY_PASSWORD=%s\n' "$RELEASE_KEY_PASSWORD"
} > secrets.properties
echo "Release keystore configured from CI secrets."
- name: Set up JDK 21
uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0
with:
distribution: temurin
java-version: "21"
- name: Set up Android SDK
uses: android-actions/setup-android@40fd30fb8d7440372e1316f5d1809ec01dcd3699 # v4.0.1
- name: Install SDK platform and build-tools
run: sdkmanager "$ANDROID_PLATFORM" "$ANDROID_BUILD_TOOLS"
- name: Set up Gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
- name: Build release AAB and APK
run: ./gradlew :app:bundleRelease :app:assembleRelease --stacktrace
- name: Generate changelog from Conventional-Commit history
env:
RELEASE_TAG: ${{ steps.plan.outputs.release_tag }}
VERSION: ${{ steps.plan.outputs.version }}
SIGNED: ${{ steps.plan.outputs.signed }}
run: |
set -euo pipefail
mkdir -p dist/whatsnew
# Nearest tag strictly before HEAD (HEAD itself is the release tag on tag builds).
prev="$(git describe --tags --abbrev=0 HEAD~1 2>/dev/null || true)"
range="HEAD"
[ -n "$prev" ] && range="$prev..HEAD"
echo "Changelog range: $range"
log() { git log --no-merges --pretty='- %s (%h)' "$range"; }
changelog="dist/CHANGELOG.md"
printf '## LibreMail %s\n\n' "$VERSION" > "$changelog"
if [ "$SIGNED" != "true" ]; then
printf '> **Warning:** built without a release keystore — the attached binaries are debug-key signed placeholders and are **not** suitable for installation from app stores.\n\n' >> "$changelog"
fi
section() { # $1 = grep -E pattern over "- subject (hash)" lines, $2 = heading
local body
body="$(log | grep -E "$1" || true)"
if [ -n "$body" ]; then
printf '### %s\n\n%s\n\n' "$2" "$body" >> "$changelog"
fi
}
section '^- [a-z]+(\([^)]*\))?!:' 'Breaking changes'
section '^- feat[(!:]' 'Features'
section '^- fix[(!:]' 'Bug fixes'
section '^- perf[(!:]' 'Performance'
section '^- (docs|chore|ci|build|refactor|test|style)[(!:]' 'Maintenance'
# Anything that is not a Conventional Commit:
other="$(log | grep -Ev '^- (feat|fix|perf|docs|chore|ci|build|refactor|test|style)[(!:]' || true)"
if [ -n "$other" ]; then
printf '### Other changes\n\n%s\n\n' "$other" >> "$changelog"
fi
if ! log | grep -q .; then
printf '_No changes since %s._\n\n' "${prev:-the initial commit}" >> "$changelog"
fi
if [ -n "$prev" ] && [ -n "$RELEASE_TAG" ]; then
printf '**Full changelog**: https://github.com/%s/compare/%s...%s\n' "$GITHUB_REPOSITORY" "$prev" "$RELEASE_TAG" >> "$changelog"
fi
# Google Play "what's new" (500-char limit): user-facing entries only.
notes="$(log | grep -E '^- (feat|fix)[(!:]' | head -20 || true)"
[ -z "$notes" ] && notes="- Maintenance release"
printf 'LibreMail %s\n\n%s\n' "$VERSION" "$notes" | head -c 490 > dist/whatsnew/whatsnew-en-US
echo "----- CHANGELOG.md -----"
cat "$changelog"
- name: Stage artifacts and compute SHA-256 checksums
env:
VERSION: ${{ steps.plan.outputs.version }}
SIGNED: ${{ steps.plan.outputs.signed }}
run: |
set -euo pipefail
suffix=""
[ "$SIGNED" != "true" ] && suffix="-unsigned"
apk="$(find app/build/outputs/apk/release -name '*.apk' -print -quit)"
cp "$apk" "dist/LibreMail-${VERSION}${suffix}.apk"
cp app/build/outputs/bundle/release/app-release.aab "dist/LibreMail-${VERSION}${suffix}.aab"
# R8 mapping for de-obfuscating crash reports (isMinifyEnabled = true).
cp app/build/outputs/mapping/release/mapping.txt "dist/LibreMail-${VERSION}-mapping.txt"
# Source archives with only git-tracked files at the released commit (GPL §6
# convenience: source travels alongside every distributed binary).
git archive --format=zip --prefix="LibreMail-${VERSION}/" -o "dist/LibreMail-${VERSION}-src.zip" HEAD
git archive --format=tar --prefix="LibreMail-${VERSION}/" HEAD | gzip > "dist/LibreMail-${VERSION}-src.tar.gz"
(cd dist && sha256sum LibreMail-* > SHA256SUMS.txt)
ls -l dist
cat dist/SHA256SUMS.txt
- name: Upload release artifacts
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: release-dist
path: dist/
if-no-files-found: error
github-release:
name: Create GitHub release
needs: [build]
if: needs.build.outputs.publish == 'true'
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: write
steps:
- name: Download release artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: release-dist
path: dist
- name: Create or update the release
uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1
with:
tag_name: ${{ inputs.tag }}
name: ${{ inputs.tag }}
target_commitish: ${{ github.sha }}
prerelease: ${{ inputs.prerelease }}
tag_name: ${{ needs.build.outputs.release_tag }}
name: ${{ needs.build.outputs.release_tag }}
body_path: dist/CHANGELOG.md
prerelease: ${{ needs.build.outputs.prerelease == 'true' }}
generate_release_notes: true
fail_on_unmatched_files: true
files: |
dist/LibreMail-${{ inputs.tag }}.apk
dist/LibreMail-${{ inputs.tag }}-src.zip
dist/LibreMail-${{ inputs.tag }}-src.tar.gz
dist/LibreMail-*
dist/SHA256SUMS.txt
google-play:
name: Publish to Google Play
needs: [build]
# Requires publishable (release-signed) artifacts AND Play credentials; the build job's
# plan step logs a notice/warning explaining any skip.
if: needs.build.outputs.publish == 'true' && needs.build.outputs.signed == 'true' && needs.build.outputs.have_play == 'true'
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Download release artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: release-dist
path: dist
- name: Determine track and staged-rollout mode
id: mode
env:
TRACK: ${{ inputs.play_track || 'internal' }}
FRACTION: ${{ inputs.play_rollout_fraction || '0.10' }}
run: |
set -euo pipefail
status="completed"
fraction=""
if [ "$TRACK" = "production" ]; then
case "$FRACTION" in
0 | 0.0 | 0.00)
echo "::error::play_rollout_fraction must be greater than 0 (got '$FRACTION')."
exit 1
;;
1 | 1.0 | 1.00)
status="completed" # full rollout
;;
0.[0-9]*)
status="inProgress" # staged rollout
fraction="$FRACTION"
;;
*)
echo "::error::play_rollout_fraction must be a fraction like 0.10 (0 < f < 1) or 1.0 for a full rollout (got '$FRACTION')."
exit 1
;;
esac
fi
echo "Publishing to track '$TRACK' with status '$status'${fraction:+ (user fraction $fraction)}."
{
echo "track=$TRACK"
echo "status=$status"
echo "fraction=$fraction"
} >> "$GITHUB_OUTPUT"
- name: Upload to Google Play
uses: r0adkll/upload-google-play@e738b9dd8f2476ea806d921b64aacd24f34515a5 # v1.1.5
with:
serviceAccountJsonPlainText: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}
packageName: org.libremail.app
releaseFiles: dist/LibreMail-*.aab
track: ${{ steps.mode.outputs.track }}
status: ${{ steps.mode.outputs.status }}
userFraction: ${{ steps.mode.outputs.fraction }}
whatsNewDirectory: dist/whatsnew
mappingFile: dist/LibreMail-${{ needs.build.outputs.version }}-mapping.txt
# Samsung provides no maintained GitHub Action, and its Content Publish API is mid-
# migration (contentUpdate's binaryList parameter stops being accepted in July 2026), so
# automated submission is deliberately stubbed until #17 lands store credentials and the
# API settles. This job documents the state and the manual path; docs/release.md#galaxy-store
# has the full instructions. The GALAXY_* secret names are reserved for the future wiring.
galaxy-store:
name: Publish to Galaxy Store (manual for now)
needs: [build]
if: needs.build.outputs.publish == 'true'
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Explain the Galaxy Store publication path
env:
HAVE_GALAXY: ${{ needs.build.outputs.have_galaxy }}
RELEASE_TAG: ${{ needs.build.outputs.release_tag }}
run: |
set -euo pipefail
if [ "$HAVE_GALAXY" = "true" ]; then
echo "::notice::GALAXY_* secrets are configured, but automated Galaxy Store submission is intentionally disabled: Samsung ships no maintained GitHub Action and its Content Publish API is mid-migration (binaryList removal, July 2026). Publish manually for now — see docs/release.md#galaxy-store."
else
echo "::notice::Galaxy Store credentials (GALAXY_SERVICE_ACCOUNT_ID / GALAXY_PRIVATE_KEY / GALAXY_CONTENT_ID) are not configured — publish manually. See docs/release.md#galaxy-store."
fi
cat <<EOF
Manual Galaxy Store publication for $RELEASE_TAG:
1. Download LibreMail-$RELEASE_TAG.apk (and SHA256SUMS.txt) from the GitHub release.
2. Verify the checksum: sha256sum -c SHA256SUMS.txt
3. Sign in to Samsung Seller Portal (https://seller.samsungapps.com), open the
LibreMail app entry, upload the APK as a new binary, update the release notes
from CHANGELOG.md and submit for review.
EOF
s3-archive:
name: Archive binaries to S3
needs: [build]
if: needs.build.outputs.publish == 'true' && needs.build.outputs.have_s3 == 'true'
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Download release artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: release-dist
path: dist
# Uses the AWS CLI preinstalled on ubuntu runners; --endpoint-url makes it work with
# any S3-compatible provider (MinIO, Backblaze B2, Cloudflare R2, …). Keys are
# versioned per release under releases/<tag>/, with SHA256SUMS.txt stored alongside.
- name: Upload artifacts and checksums
env:
AWS_ACCESS_KEY_ID: ${{ secrets.ARCHIVE_S3_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.ARCHIVE_S3_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: ${{ secrets.ARCHIVE_S3_REGION || 'us-east-1' }}
S3_BUCKET: ${{ secrets.ARCHIVE_S3_BUCKET }}
S3_ENDPOINT: ${{ secrets.ARCHIVE_S3_ENDPOINT }}
RELEASE_TAG: ${{ needs.build.outputs.release_tag }}
run: |
set -euo pipefail
endpoint_args=()
[ -n "$S3_ENDPOINT" ] && endpoint_args+=(--endpoint-url "$S3_ENDPOINT")
dest="s3://${S3_BUCKET}/releases/${RELEASE_TAG}/"
aws s3 cp dist/ "$dest" --recursive --exclude "whatsnew/*" "${endpoint_args[@]}"
echo "Archived release artifacts to $dest:"
aws s3 ls "$dest" "${endpoint_args[@]}"
# Single aggregating result (mirrors ci.yml's ci-passed): fails if any stage failed, and
# writes a per-stage summary — including why gated stages were skipped — to the run page.
release-summary:
name: Release summary
needs: [fast-gate, build, github-release, google-play, galaxy-store, s3-archive]
if: always()
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Write summary
env:
R_GATE: ${{ needs.fast-gate.result }}
R_BUILD: ${{ needs.build.result }}
R_RELEASE: ${{ needs.github-release.result }}
R_PLAY: ${{ needs.google-play.result }}
R_GALAXY: ${{ needs.galaxy-store.result }}
R_S3: ${{ needs.s3-archive.result }}
O_TAG: ${{ needs.build.outputs.release_tag }}
O_PUBLISH: ${{ needs.build.outputs.publish }}
O_SIGNED: ${{ needs.build.outputs.signed }}
O_PLAY: ${{ needs.build.outputs.have_play }}
O_S3: ${{ needs.build.outputs.have_s3 }}
run: |
set -euo pipefail
note_release=""
if [ "$O_PUBLISH" != "true" ]; then note_release="dry run / rehearsal — nothing published"; fi
note_play=""
if [ "$O_PLAY" != "true" ]; then
note_play="needs PLAY_SERVICE_ACCOUNT_JSON"
elif [ "$O_SIGNED" != "true" ]; then
note_play="unsigned build — needs RELEASE_* signing secrets"
fi
note_s3=""
if [ "$O_S3" != "true" ]; then note_s3="needs ARCHIVE_S3_* secrets"; fi
{
echo "## Release pipeline — ${O_TAG:-rehearsal (no tag)}"
echo
echo "| Stage | Result | Notes |"
echo "| --- | --- | --- |"
echo "| Fast CI gate | $R_GATE | |"
echo "| Build + sign | $R_BUILD | signed: ${O_SIGNED:-n/a} |"
echo "| GitHub release | $R_RELEASE | $note_release |"
echo "| Google Play | $R_PLAY | $note_play |"
echo "| Galaxy Store | $R_GALAXY | manual for now — docs/release.md#galaxy-store |"
echo "| S3 archive | $R_S3 | $note_s3 |"
echo
echo "Secret setup: docs/release.md"
} >> "$GITHUB_STEP_SUMMARY"
- name: Fail if any stage failed
if: ${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') }}
run: |
echo "A release stage failed or was cancelled:"
echo " fast-gate: ${{ needs.fast-gate.result }}"
echo " build: ${{ needs.build.result }}"
echo " github-release: ${{ needs.github-release.result }}"
echo " google-play: ${{ needs.google-play.result }}"
echo " galaxy-store: ${{ needs.galaxy-store.result }}"
echo " s3-archive: ${{ needs.s3-archive.result }}"
exit 1
+3
View File
@@ -41,3 +41,6 @@ captures/
# Kotlin
.kotlin/
# Claude Code — personal settings (the shared settings.json IS committed)
.claude/settings.local.json
+66
View File
@@ -0,0 +1,66 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
LibreMail is a GPL-3.0 Android email client (Kotlin, Jetpack Compose, Material 3).
See `@README.md` for OAuth/account setup (Gmail, Outlook), the tech stack, and the
offline-first architecture — this file only covers what isn't obvious from the code.
## Build, test, lint
Use a **JDK 17–21** for the Gradle daemon. AGP 9.2 does **not** support JDK 25 — if
`JAVA_HOME` points at 25+, builds fail. Commands (PowerShell: use `.\gradlew`):
```bash
./gradlew :app:assembleDebug # build debug APK
./gradlew :app:testDebugUnitTest # JVM unit tests
./gradlew :app:lintDebug # Android lint
./gradlew :app:ktlintCheck :app:detekt # static analysis (CI's "Static analysis" gate)
# single unit test:
./gradlew :app:testDebugUnitTest --tests "org.libremail.data.SomeClassTest"
```
E2E/instrumented tests need a booted emulator: `./gradlew :app:connectedDebugAndroidTest`,
or via Gradle Managed Devices `./gradlew e2eGroupDebugAndroidTest` (whole matrix) /
`./gradlew api29DebugAndroidTest` (one API level). The managed-device list in
`app/build.gradle.kts` must stay in lockstep with the E2E matrix in `.github/workflows/ci.yml`.
**Before treating a change as done**, run the fast CI gate: `assembleDebug` +
`testDebugUnitTest` + `lintDebug` + `ktlintCheck` + `detekt` (the `/preflight` skill does
this). `ktlintCheck`/`detekt` cover the `test`/`androidTest` source sets that `lintDebug`
skips, so they catch style violations that would otherwise fail CI's Static analysis gate.
Emulator E2E is left to CI unless asked.
## Build-config gotchas
- **Built-in Kotlin (AGP 9.x).** Kotlin compilation is handled by AGP's built-in Kotlin;
the Kotlin version (2.4.0) is pinned via the root `build.gradle.kts` buildscript classpath.
**Never apply the `org.jetbrains.kotlin.android` plugin** — it throws a ClassCastException
against AGP 9's DSL. (The `kotlin-android` alias in `libs.versions.toml` exists but must
not be used.) `libs.versions.toml` still supplies all *library* versions.
- **KSP, not KAPT** for all annotation processing (Hilt, Room).
- Room schemas are exported to `app/schemas` and validated by migration tests — commit
schema changes.
- OAuth client IDs come from `secrets.properties` (git-ignored) via `BuildConfig`; the
build works without it (empty/placeholder values).
## Code conventions
- Sources live under `app/src/{main,test,androidTest}/kotlin/`; package root `org.libremail`
(applicationId `org.libremail.app`).
- **Every source file starts with** `// SPDX-License-Identifier: GPL-3.0-or-later` (or the
`<!-- ... -->` form for XML/Markdown). All 117 current `.kt` files follow this.
- `kotlin.code.style=official`.
## Testing
JVM unit tests use JUnit4 + `kotlin.test`, **Turbine** for `Flow`, **MockK** for mocks,
**GreenMail** for a real in-process IMAP/SMTP server, and coroutines-test. `org.json` is
pulled in as a real dependency for unit tests because `android.jar`'s version is a no-op stub.
## Repo etiquette
- Branch off `main`; branch names like `feat-…` / `fix-…`. PRs target `main` and must pass
the `CI passed` gate.
- **Conventional Commits** for commit subjects and PR titles: `type(scope): summary`
(`feat`, `fix`, `chore`, …), matching existing history.
+135
View File
@@ -0,0 +1,135 @@
<!-- SPDX-License-Identifier: GPL-3.0-or-later -->
# LibreMail Privacy Policy
**Effective date: 2026-07-01** · Applies to the LibreMail Android app (`org.libremail.app`).
LibreMail is a free and open-source (GPL-3.0-or-later) email client. This policy describes what
the app does with your data. Because the source code is public, every statement here can be
verified against the code at <https://github.com/JMR-dev/LibreMail>.
## Summary
- **We run no servers and receive no data from you.** The LibreMail project has no backend: the
app talks only to the email provider(s) *you* configure (e.g. your Gmail, Outlook, Yahoo,
iCloud, or self-hosted IMAP/SMTP server) and, for Outlook accounts, to Microsoft's sign-in and
Graph endpoints.
- **Your mail stays on your device.** Messages are cached locally so the app works offline; the
cache can optionally be encrypted at rest.
- **No ads, no analytics, no tracking.** The app contains no advertising, analytics, or tracking
SDK of any kind, and no Google Play Services or Firebase dependency.
- **Nothing is sent to the developers** — including crash reports, which are strictly opt-in,
stored locally, shown to you for review, and (in this build) cannot be uploaded at all because
no ingest endpoint is configured.
## What the app stores on your device
All of the following lives in the app's private storage on your device only:
- **Account settings** — your email address, display name, and server host/port/security
settings for each account you add.
- **Credentials** — your per-account app password or OAuth tokens, encrypted with a hardware-
backed key in the Android Keystore before being written to storage.
- **Mail cache** — headers, message bodies, and folder state, in a local database so your mail is
available offline. You can optionally enable **cache encryption** (SQLCipher) in Settings; the
database key is random, never leaves the device, and is itself sealed by the Android Keystore.
- **Attachments** you download or attach, in the app's cache directory (Android may clear this
automatically to reclaim space).
- **Preferences** — theme, notification, sync, and privacy toggles.
- **Debug reports** — only if a crash occurs or you ask the app to capture one; see
[Diagnostics](#diagnostics-and-debug-reports).
Uninstalling the app, or clearing its storage in Android settings, deletes all of the above.
## What leaves your device
The app makes network connections **only** to servers that operate your email service:
- **Your mail servers** — the IMAP and SMTP hosts of each account you configure (for the built-in
presets: `imap/smtp.gmail.com`, `imap/smtp.mail.yahoo.com`, `imap/smtp.mail.me.com`;
`outlook.office.com` for Outlook). This traffic is your email itself: signing in, downloading
your mail, sending the messages you write, and — when you use server search — your search
query. That is the app doing its job as your email client; none of it goes to us.
- **Microsoft identity platform and Graph** (`login.microsoftonline.com`,
`graph.microsoft.com`) — only for Outlook/Hotmail accounts, to sign you in with OAuth 2.0 and
to send mail via Microsoft's API.
- **Remote images in emails** — blocked by default. If you enable "load remote images", the
message viewer will fetch images from the servers referenced by the email (which can reveal
your IP address to the sender), so it stays off unless you turn it on.
Every mail connection uses TLS (SSL/TLS or STARTTLS) with server-certificate hostname
verification; the account-setup UI does not offer an unencrypted option.
The app never transmits your data to the LibreMail project or any third party of ours. There is
no telemetry, no "phone home", and no ad or analytics traffic.
## Contacts (`READ_CONTACTS` permission)
When composing a message, LibreMail can suggest recipients from your device contacts. The app
asks for the contacts permission the first time you open the compose screen:
- Contact lookups run **entirely on the device** and return at most a handful of name/email
matches for what you typed. Your contact list is never uploaded, copied, or synced anywhere.
- The only way a contact detail leaves the device is when *you* put an address in an email you
send — it then appears in that email, like in any mail client.
- The permission is optional: if you deny it, autocomplete is silently disabled and everything
else keeps working.
## Notifications (`POST_NOTIFICATIONS` permission)
Used to show new-mail notifications (per-account, with sender/subject hidden on a locked screen)
and the persistent low-priority status notification Android requires while the optional
instant-push connection is active. New-mail notifications are generated **on the device** from
your synced mail — there is no push server and no cloud messaging service involved. You can
decline the permission or disable notifications per account in system settings.
## Instant push (foreground service)
For instant mail delivery the app can hold an open IMAP IDLE connection to your mail server in a
foreground service (shown as a persistent notification). This connects only to your own mail
server, and can be turned off in Settings ("push mail"), which falls back to periodic background
sync.
## Diagnostics and debug reports
LibreMail has **no automatic crash or usage reporting**. What exists instead:
- If the app crashes, or you use "Report a problem", a report is saved **locally** on your
device. It contains the app version, Android version, device make/model, a stack trace (for
crashes), a short summary of non-identifying settings, and recent internal log lines — by
design no account addresses, server names, or message content fields are collected.
- You can view the full report text (with a plain-language notice to check it for anything
personal), copy it, share it yourself, or delete it. It is transmitted **only** if you
explicitly tap Submit — never in the background.
- In the builds produced from this repository **no upload endpoint is configured**, so even an
explicit Submit cannot send anything; the report simply stays on your device. If a future
release adds an endpoint, submission will remain strictly opt-in and user-initiated, and this
policy will be updated.
## Android Backup
Android's cloud backup is **off by default** for LibreMail. If you enable "Include settings in
Android Backup" in Settings, only your app preferences are backed up through your device's
Android Backup transport (typically Google's). Your credentials, the mail cache, and the cache
encryption key are always excluded from backups.
## Data deletion
- **Remove an account** (in the app's account settings) — deletes that account's stored
credentials, its cached messages, folders, and per-account settings from the local database,
and its notification channels. Copies of downloaded attachments in the app's cache directory
are cleared by Android's normal cache management, or immediately via "Clear cache" in system
settings.
- **Uninstall the app / clear storage** — removes all locally stored app data.
- **Your mailbox is unaffected**: mail lives with your email provider; deleting data in
LibreMail does not delete mail from the server unless you explicitly delete messages in the
app. We hold no copy of your data, so there is nothing for us to delete on any server.
## Children
LibreMail is a general-audience utility that requires an existing email account. It is not
directed at children, and — as described above — it collects no data from any user.
## Changes and contact
Changes to this policy are made in the public repository with full version history. Questions or
concerns: open an issue at <https://github.com/JMR-dev/LibreMail/issues>.
+91 -40
View File
@@ -5,28 +5,37 @@ A free and open-source email client for Android, built with Kotlin, Jetpack
Compose and Material 3 (Material You). LibreMail aims for a friendly default
experience with power-user features tucked under an **Advanced Settings** group.
> Status: **in development.** Material You shell; **account setup** (Gmail OAuth via
> AppAuth/PKCE and generic IMAP/SMTP, with a live connection test and Keystore-
> encrypted credentials); **IMAP receive** — background sync (WorkManager) into a local
> Room cache with pull-to-refresh; and **reading** — message bodies fetched on open and
> rendered in a hardened WebView (JavaScript off, remote images blocked by default),
> with mark-read, star, and delete; and **composing** — a compose screen with device-
> contacts autocomplete that sends via a reliable background **outbox** (WorkManager-queued
> and retried, with a viewable outbox folder), plus reply and **drafts** saved for
> later; **on-device new-mail
> notifications** (no push service) with persisted settings; **instant push** via a
> foreground **IMAP IDLE** service; **attachments** — downloaded on demand and opened in a
> system viewer, and attach files when composing; **multiple accounts** — a unified inbox
> with per-account filtering; and
> **search** across cached mail and the server (IMAP SEARCH); and **Outlook/Microsoft**
> accounts (OAuth 2.0 sign-in, IMAP receive + Microsoft Graph send, SMTP/XOAUTH2 fallback).
> Status: **in development.** Material You shell; **onboarding** — a first-run flow from a
> welcome screen through a vendor picker (Outlook/Hotmail, Gmail, Yahoo, iCloud, or Other) and
> per-vendor setup to your first account's inbox; **account setup** — Outlook/Microsoft via
> OAuth 2.0 (AppAuth/PKCE), Gmail/Yahoo/iCloud via app password, and generic IMAP/SMTP, all
> with a live connection test and Keystore-encrypted credentials; **IMAP receive** — background
> sync (WorkManager) into a local Room cache with pull-to-refresh, backfilling your **entire**
> mail history (resumable) with an optional device-only retention cap; **reading** — message
> bodies fetched on open and rendered in a hardened WebView (JavaScript off, remote images
> blocked by default), with mark-read, star, and delete; **composing** — a rich-text HTML
> editor with a formatting toolbar and per-account signatures that sends
> `multipart/alternative` (HTML with a plaintext fallback) through a reliable background
> **outbox** (WorkManager-queued and retried, with a viewable outbox folder), plus
> device-contacts autocomplete, reply, and **drafts**; **on-device new-mail notifications** (no
> push service) with persisted settings; **instant push** via a foreground **IMAP IDLE**
> service; **attachments** — downloaded on demand and opened in a system viewer, and attach
> files when composing; **multiple accounts** — a unified inbox with per-account filtering;
> **search** across cached mail and the server (IMAP SEARCH); Outlook/Microsoft send via
> Microsoft **Graph** with an SMTP/XOAUTH2 fallback; an opt-in **app lock**
> (biometric/device-credential) that binds the encrypted cache key to your unlock; **mailto:**
> link handling with optional default-mail-app registration; and opt-in, F-Droid-safe **debug
> reporting** — local crash/error capture that you review (with a PII disclaimer) and submit
> only on an explicit action.
## Features (target MVP)
- Send and receive email with **Gmail** and **Outlook/Microsoft** (OAuth 2.0) and **any IMAP/SMTP** provider.
- Send and receive email with **Outlook/Microsoft** (OAuth 2.0), **Gmail, Yahoo and iCloud** (app password), and **any other IMAP/SMTP** provider.
- Guided first-run onboarding: welcome → vendor picker → per-vendor setup → your inbox.
- Material You dynamic theming, light/dark, edge-to-edge.
- Clean compose screen with phone/account contacts integration.
- Modern security: OAuth 2.0 Authorization Code + PKCE, no stored passwords for Gmail.
- Rich-text compose with a formatting toolbar, per-account signatures, and phone/account contacts integration.
- Offline-first: a local Room cache with full-history backfill and an optional device-only retention limit.
- Modern, opt-in security: OAuth 2.0 (Authorization Code + PKCE) for Outlook, Keystore-encrypted credentials, optional SQLCipher cache encryption, and a biometric/device-credential app lock.
## Tech stack
@@ -67,27 +76,27 @@ sdkmanager "platforms;android-37.0" "build-tools;37.0.0"
`local.properties` (git-ignored) must point `sdk.dir` at your Android SDK; Android
Studio creates it automatically.
## Gmail account setup (OAuth client)
## Accounts and onboarding
Gmail IMAP/SMTP requires the restricted `https://mail.google.com/` scope. While the
app is unpublished you can use it in **Testing** mode with up to 100 test users and
no security assessment; a public Play Store release later requires a Google CASA
assessment for the restricted scope.
On first launch LibreMail runs a short onboarding flow: a welcome screen, a **vendor picker**
(Outlook/Hotmail, Gmail, Yahoo, iCloud, or **Other**), per-vendor setup, and an "add another
account?" prompt before it drops you on your first account's inbox. You can add more accounts
later from settings; a unified inbox merges them with per-account filtering.
1. In the [Google Cloud Console](https://console.cloud.google.com/), create a
project (e.g. *LibreMail*).
2. **APIs & Services → Library →** enable the **Gmail API**.
3. **OAuth consent screen:** user type *External*; add the scope
`https://mail.google.com/`; under **Test users**, add your Google address.
Leave the app in **Testing**.
4. **Credentials → Create credentials → OAuth client ID → Android.** Use package
name `org.libremail.app` and your debug keystore SHA-1:
```bash
keytool -list -v -keystore "$HOME/.android/debug.keystore" \
-alias androiddebugkey -storepass android -keypass android
```
5. Copy `secrets.properties.example` to `secrets.properties` (git-ignored) and set
`GMAIL_OAUTH_CLIENT_ID` to your client ID. The build injects it via `BuildConfig`.
LibreMail supports three kinds of account:
- **Outlook / Hotmail** — signs in with **OAuth 2.0** through Microsoft (AppAuth); no password
is stored. See [Outlook / Microsoft account setup](#outlook--microsoft-account-setup-oauth-client)
below.
- **Gmail, Yahoo and iCloud** — preconfigured IMAP/SMTP that authenticate with a provider
**app password** (not your normal account password), preferring STARTTLS where the provider
supports it. Onboarding links you to each vendor's app-password page. **Gmail requires
2-Step Verification to be enabled** before Google will issue an app password.
- **Other** — a manual IMAP/SMTP form (host, port, security, and credentials) for any other
provider.
App passwords and OAuth tokens are held in a credential store encrypted with the Android
Keystore, and every account runs a live connection test before it is saved.
## Outlook / Microsoft account setup (OAuth client)
@@ -108,6 +117,28 @@ token. A working client ID ships with the build; to use your own Azure app regis
4. Copy the **Application (client) ID** into `secrets.properties` as
`OUTLOOK_OAUTH_CLIENT_ID` (it overrides the built-in default).
## Privacy and data flow
LibreMail is offline-first: your mail lives in a local cache, and by default network traffic
goes only to your mail providers (IMAP/SMTP, plus Microsoft's OAuth and Graph endpoints for
Outlook). There is no analytics SDK and no always-on telemetry. The full privacy policy lives in
[`PRIVACY.md`](PRIVACY.md); Google Play compliance notes (data-safety mapping, permissions
justification) are under [`docs/`](docs/). Because Gmail uses an app password (no Google OAuth
scopes), no Google restricted-scope verification or CASA assessment applies; Outlook's OAuth
client is governed by Microsoft's Azure rules. The privacy-sensitive extras are all **opt-in**:
- **Cache encryption** — the Room cache can be encrypted at rest with **SQLCipher**. With the
optional **app lock** (biometric or device credential) enabled, the cache key is bound to
your authentication, so the database is only decrypted after you unlock the app.
- **Debug reporting** — **off by default.** When enabled, crashes and errors are captured
**locally**; you review the full report — shown with a plain-language **PII disclaimer** —
and it is sent only when you explicitly submit it, to a configurable (optional) endpoint.
There is no hosted crash pipeline collecting reports in the background.
- **Android Backup** — **off by default.** When you turn it on, only safe app settings are
backed up; the encrypted-database key, account credentials, and the mail cache are
**excluded**. Because Android's backup transport can route data through Google, it stays
disabled unless you opt in — the kind of optional behavior F-Droid lists as an anti-feature.
## Architecture
Offline-first, unidirectional, layered:
@@ -119,11 +150,31 @@ data/ Room (entities, DAOs, database) + repository implementation (source o
di/ Hilt modules
```
The UI observes Room via `Flow`; later increments add a sync engine (Angus Mail
over IMAP/SMTP) that writes into Room, and an auth layer (AppAuth + an Android
Keystore-backed credential store).
The UI observes Room via `Flow`; a sync engine (Angus Mail over IMAP/SMTP, plus Microsoft
Graph for Outlook send) writes into Room, and an auth layer (AppAuth for OAuth and an Android
Keystore-backed credential store for app passwords) handles sign-in.
## F-Droid
LibreMail is built to meet F-Droid's inclusion criteria: every dependency is
FOSS-licensed, there are no Google Play Services / Firebase / proprietary SDKs, the
build needs no `secrets.properties`, and there are **no anti-features to declare**
(the privacy-sensitive extras above are all opt-in). The full dependency license
audit, anti-feature review, and clean-room build verification live in
[`docs/fdroid-compliance.md`](docs/fdroid-compliance.md); the store listing is under
[`fastlane/metadata/android/`](fastlane/metadata/android/en-US), and
[`docs/fdroid/org.libremail.app.yml`](docs/fdroid/org.libremail.app.yml) is the
template for the eventual fdroiddata build recipe.
## License
LibreMail is licensed under the **GNU General Public License v3.0** — see
[`LICENSE`](LICENSE). SPDX identifier: `GPL-3.0-or-later`.
Onboarding requires agreeing to this license before anything else (#172); the screen that shows it
(`ui/onboarding/LicenseScreen.kt`) reads a bundled runtime copy at
`app/src/main/res/raw/license.txt` rather than this file directly. That copy is a byte-for-byte
duplicate, kept in sync by hand rather than generated at build time or annotated in place (the
in-app screen renders it verbatim, so any header/footer added to the copy would show up to the user
as if it were part of the license) — **if you edit `LICENSE`, copy the change into `license.txt`
too**, and vice versa.
+93
View File
@@ -0,0 +1,93 @@
Copyright 2020 The Inter Project Authors (https://github.com/rsms/inter)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
@@ -0,0 +1,93 @@
Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at: https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+93
View File
@@ -0,0 +1,93 @@
Copyright 2011 The Lora Project Authors (https://github.com/cyrealtype/Lora-Cyrillic), with Reserved Font Name "Lora".
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+93
View File
@@ -0,0 +1,93 @@
Copyright 2020 The Merriweather Project Authors (https://github.com/EbenSorkin/Merriweather4) with Reserved Font Name "Merriweather".
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+88 -18
View File
@@ -9,23 +9,17 @@ plugins {
id("org.jetbrains.kotlin.plugin.compose")
id("com.google.devtools.ksp")
id("com.google.dagger.hilt.android")
// Lint/format — resolved from the Gradle Plugin Portal (not the buildscript classpath).
alias(libs.plugins.ktlint)
alias(libs.plugins.detekt)
}
// Read the Gmail OAuth client id from secrets.properties (git-ignored). Empty when absent.
// Read optional build secrets (Outlook client id, release signing) from secrets.properties
// (git-ignored). Absent values fall back to the defaults below.
val secretsFile = rootProject.file("secrets.properties")
val secrets = Properties().apply {
if (secretsFile.exists()) secretsFile.inputStream().use { load(it) }
}
val gmailOAuthClientId: String = secrets.getProperty("GMAIL_OAUTH_CLIENT_ID", "")
// For a Google installed-app OAuth client, AppAuth's redirect is the reversed client
// id as a custom URI scheme. Fall back to a placeholder so the manifest stays valid
// until a real client id is set in secrets.properties.
val gmailRedirectScheme: String = if (gmailOAuthClientId.endsWith(".apps.googleusercontent.com")) {
"com.googleusercontent.apps." + gmailOAuthClientId.removeSuffix(".apps.googleusercontent.com")
} else {
"org.libremail.oauth"
}
// Microsoft (Outlook) OAuth public client id — a GUID, not a secret. Overridable via
// secrets.properties; defaults to the app's registered client id.
@@ -34,6 +28,15 @@ val outlookOAuthClientId: String = secrets.getProperty(
"04e4aa5e-ed1f-47f9-b567-b99a0b29b3df",
)
// Custom URI scheme AppAuth uses to capture the Outlook OAuth redirect. Must match the scheme of
// OUTLOOK_OAUTH_REDIRECT_URI and the redirect URI registered in the Azure app registration.
val outlookRedirectScheme = "org.libremail.outlook"
// Debug-report ingest endpoint (issue #34, out of scope for this repo). Empty by default: the debug
// reporting client is strictly opt-in and never sends anything unless the user taps Submit AND an
// endpoint is configured here (overridable via git-ignored secrets.properties).
val debugReportEndpoint: String = secrets.getProperty("DEBUG_REPORT_ENDPOINT", "")
// Optional release signing, configured via git-ignored secrets.properties. When absent, release
// builds fall back to the debug key (installable for testing, but not publishable).
val releaseStoreFile: String? = secrets.getProperty("RELEASE_STORE_FILE")
@@ -44,19 +47,20 @@ android {
defaultConfig {
applicationId = "org.libremail.app"
minSdk = 33
// Supports a rolling ~7-year window of Android versions (API 29 / Android 10, 2019 → latest).
minSdk = 29
targetSdk = 37
versionCode = 1
versionName = "0.1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
buildConfigField("String", "GMAIL_OAUTH_CLIENT_ID", "\"$gmailOAuthClientId\"")
buildConfigField("String", "GMAIL_OAUTH_REDIRECT_URI", "\"$gmailRedirectScheme:/oauth2redirect\"")
buildConfigField("String", "OUTLOOK_OAUTH_CLIENT_ID", "\"$outlookOAuthClientId\"")
buildConfigField("String", "OUTLOOK_OAUTH_REDIRECT_URI", "\"org.libremail.outlook://oauth2redirect\"")
// AppAuth captures the OAuth redirect via this custom scheme.
manifestPlaceholders["appAuthRedirectScheme"] = gmailRedirectScheme
buildConfigField("String", "OUTLOOK_OAUTH_REDIRECT_URI", "\"$outlookRedirectScheme://oauth2redirect\"")
buildConfigField("String", "DEBUG_REPORT_ENDPOINT", "\"$debugReportEndpoint\"")
// AppAuth's bundled manifest requires this placeholder; it registers the redirect scheme on
// RedirectUriReceiverActivity so the Outlook sign-in redirect returns to the app.
manifestPlaceholders["appAuthRedirectScheme"] = outlookRedirectScheme
}
signingConfigs {
@@ -92,6 +96,22 @@ android {
buildConfig = true
}
// Ship the exported Room schemas as androidTest assets so MigrationTestHelper can load them.
sourceSets.getByName("androidTest").assets.srcDir("$projectDir/schemas")
// F-Droid compliance (issue #16): by default AGP embeds a "dependency info block" in the APK
// signing block — a list of every dependency, encrypted so that ONLY Google Play can read it.
// F-Droid's inclusion policy treats that opaque, Google-only blob as a blocker (it cannot be
// verified from source and breaks reproducible builds), so keep it out of APKs and bundles.
// See docs/fdroid-compliance.md.
dependenciesInfo {
includeInApk = false
includeInBundle = false
}
// Ship the exported Room schemas as androidTest assets so MigrationTestHelper can load them.
sourceSets.getByName("androidTest").assets.srcDir("$projectDir/schemas")
packaging {
resources {
// Angus Mail / Jakarta Activation (added later) ship duplicate META-INF entries.
@@ -104,13 +124,46 @@ android {
)
}
}
testOptions {
// Gradle Managed Devices define the per-API E2E matrix as config-as-code: one virtual
// device per supported Android version (a rolling ~7-year window, API 29 → latest stable).
// Run the whole matrix with `./gradlew e2eGroupDebugAndroidTest`, or one level with e.g.
// `./gradlew api29DebugAndroidTest`. Gradle provisions/boots/tears down the emulators and
// downloads the system images on first use. Keep this list in lockstep with the CI matrix in
// .github/workflows/ci.yml; when a new Android ships, add it and drop the oldest level that
// has fallen outside ~7 years. API 37 (preview) is exercised on the dev emulator until a
// stable managed-device image is published, so it is intentionally not listed here.
managedDevices {
localDevices {
listOf(29, 30, 31, 32, 33, 34, 35, 36).forEach { api ->
create("api$api") {
device = "Pixel 2"
apiLevel = api
systemImageSource = "google_apis"
}
}
}
groups {
create("e2e") {
targetDevices.addAll(localDevices)
}
}
}
}
}
// Export Room schemas so migrations can be validated by instrumented MigrationTestHelper tests.
// Export Room schemas so the instrumented MigrationTest can replay and validate each migration.
ksp {
arg("room.schemaLocation", "$projectDir/schemas")
}
detekt {
// Merge the project overrides in config/detekt onto detekt's bundled defaults.
buildUponDefaultConfig = true
config.setFrom(rootProject.file("config/detekt/detekt.yml"))
}
dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
@@ -118,6 +171,8 @@ dependencies {
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.navigation.compose)
implementation(libs.androidx.webkit)
implementation(libs.androidx.biometric)
implementation(libs.kotlinx.coroutines.android)
// Email transport (IMAP/SMTP) + OAuth
@@ -145,20 +200,35 @@ dependencies {
implementation(libs.androidx.room.runtime)
implementation(libs.androidx.room.ktx)
implementation(libs.androidx.room.paging)
ksp(libs.androidx.room.compiler)
implementation(libs.sqlcipher.android)
// Paging 3 — the unified inbox list is paged so its cost scales with the screen (issue #124).
implementation(libs.androidx.paging.runtime)
implementation(libs.androidx.paging.compose)
// Raise kotlinx-serialization to the version Room's schema-bundle serializers were compiled
// against (see libs.versions.toml). AGP 9 consistent resolution shares it with the androidTest
// classpath so MigrationTestHelper can parse the exported schema JSON.
implementation(platform(libs.kotlinx.serialization.bom))
testImplementation(libs.junit)
testImplementation(libs.kotlin.test)
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.turbine)
testImplementation(libs.mockk)
testImplementation(libs.greenmail)
// asSnapshot() drives a PagingData flow to a concrete list in JVM unit tests (issue #124).
testImplementation(libs.androidx.paging.testing)
// The real org.json for unit tests (android.jar ships a stubbed, no-op version).
testImplementation("org.json:json:20231013")
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(libs.androidx.espresso.intents)
androidTestImplementation(libs.androidx.test.rules)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
androidTestImplementation(libs.androidx.room.testing)
}
@@ -0,0 +1,234 @@
{
"formatVersion": 1,
"database": {
"version": 1,
"identityHash": "f2bbe80e572de72f50869b14aca4c4bb",
"entities": [
{
"tableName": "accounts",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `email` TEXT NOT NULL, `displayName` TEXT NOT NULL, `authType` TEXT NOT NULL, `imap_host` TEXT NOT NULL, `imap_port` INTEGER NOT NULL, `imap_security` TEXT NOT NULL, `smtp_host` TEXT NOT NULL, `smtp_port` INTEGER NOT NULL, `smtp_security` TEXT NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "email",
"columnName": "email",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "displayName",
"columnName": "displayName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "authType",
"columnName": "authType",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "imap.host",
"columnName": "imap_host",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "imap.port",
"columnName": "imap_port",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "imap.security",
"columnName": "imap_security",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "smtp.host",
"columnName": "smtp_host",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "smtp.port",
"columnName": "smtp_port",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "smtp.security",
"columnName": "smtp_security",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "credentials",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `encryptedSecret` TEXT NOT NULL, PRIMARY KEY(`accountId`))",
"fields": [
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "encryptedSecret",
"columnName": "encryptedSecret",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"accountId"
]
}
},
{
"tableName": "account_settings",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `signature` TEXT NOT NULL, `signatureEnabled` INTEGER NOT NULL, `notificationsEnabled` INTEGER NOT NULL, `retentionCount` INTEGER, `retentionMonths` INTEGER, PRIMARY KEY(`accountId`), FOREIGN KEY(`accountId`) REFERENCES `accounts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "signature",
"columnName": "signature",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "signatureEnabled",
"columnName": "signatureEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationsEnabled",
"columnName": "notificationsEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "retentionCount",
"columnName": "retentionCount",
"affinity": "INTEGER"
},
{
"fieldPath": "retentionMonths",
"columnName": "retentionMonths",
"affinity": "INTEGER"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"accountId"
]
},
"foreignKeys": [
{
"table": "accounts",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"accountId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "signatures",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `accountId` TEXT NOT NULL, `name` TEXT NOT NULL, `contentHtml` TEXT NOT NULL, `isDefault` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`accountId`) REFERENCES `accounts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "contentHtml",
"columnName": "contentHtml",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "isDefault",
"columnName": "isDefault",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_signatures_accountId",
"unique": false,
"columnNames": [
"accountId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_signatures_accountId` ON `${TABLE_NAME}` (`accountId`)"
}
],
"foreignKeys": [
{
"table": "accounts",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"accountId"
],
"referencedColumns": [
"id"
]
}
]
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'f2bbe80e572de72f50869b14aca4c4bb')"
]
}
}
@@ -0,0 +1,523 @@
{
"formatVersion": 1,
"database": {
"version": 10,
"identityHash": "aff2afe197ff1b0b41630e5a165d09f3",
"entities": [
{
"tableName": "accounts",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `email` TEXT NOT NULL, `displayName` TEXT NOT NULL, `authType` TEXT NOT NULL, `imap_host` TEXT NOT NULL, `imap_port` INTEGER NOT NULL, `imap_security` TEXT NOT NULL, `smtp_host` TEXT NOT NULL, `smtp_port` INTEGER NOT NULL, `smtp_security` TEXT NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "email",
"columnName": "email",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "displayName",
"columnName": "displayName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "authType",
"columnName": "authType",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "imap.host",
"columnName": "imap_host",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "imap.port",
"columnName": "imap_port",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "imap.security",
"columnName": "imap_security",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "smtp.host",
"columnName": "smtp_host",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "smtp.port",
"columnName": "smtp_port",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "smtp.security",
"columnName": "smtp_security",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "account_settings",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `signature` TEXT NOT NULL, `signatureEnabled` INTEGER NOT NULL, `notificationsEnabled` INTEGER NOT NULL, PRIMARY KEY(`accountId`), FOREIGN KEY(`accountId`) REFERENCES `accounts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "signature",
"columnName": "signature",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "signatureEnabled",
"columnName": "signatureEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationsEnabled",
"columnName": "notificationsEnabled",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"accountId"
]
},
"foreignKeys": [
{
"table": "accounts",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"accountId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "messages",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `accountId` TEXT NOT NULL, `sender` TEXT NOT NULL, `senderEmail` TEXT NOT NULL, `subject` TEXT NOT NULL, `snippet` TEXT NOT NULL, `body` TEXT NOT NULL, `isHtml` INTEGER NOT NULL, `timestampMillis` INTEGER NOT NULL, `isRead` INTEGER NOT NULL, `isStarred` INTEGER NOT NULL, `folder` TEXT NOT NULL DEFAULT 'INBOX', `inInbox` INTEGER NOT NULL, `bodyFetched` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sender",
"columnName": "sender",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "senderEmail",
"columnName": "senderEmail",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "subject",
"columnName": "subject",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "snippet",
"columnName": "snippet",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "body",
"columnName": "body",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "isHtml",
"columnName": "isHtml",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "timestampMillis",
"columnName": "timestampMillis",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isRead",
"columnName": "isRead",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isStarred",
"columnName": "isStarred",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "folder",
"columnName": "folder",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "'INBOX'"
},
{
"fieldPath": "inInbox",
"columnName": "inInbox",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "bodyFetched",
"columnName": "bodyFetched",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_messages_accountId",
"unique": false,
"columnNames": [
"accountId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_messages_accountId` ON `${TABLE_NAME}` (`accountId`)"
},
{
"name": "index_messages_timestampMillis",
"unique": false,
"columnNames": [
"timestampMillis"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_messages_timestampMillis` ON `${TABLE_NAME}` (`timestampMillis`)"
}
]
},
{
"tableName": "credentials",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `encryptedSecret` TEXT NOT NULL, PRIMARY KEY(`accountId`))",
"fields": [
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "encryptedSecret",
"columnName": "encryptedSecret",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"accountId"
]
}
},
{
"tableName": "attachments",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`messageId` TEXT NOT NULL, `partIndex` INTEGER NOT NULL, `filename` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `sizeBytes` INTEGER NOT NULL, PRIMARY KEY(`messageId`, `partIndex`), FOREIGN KEY(`messageId`) REFERENCES `messages`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "messageId",
"columnName": "messageId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "partIndex",
"columnName": "partIndex",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "filename",
"columnName": "filename",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "mimeType",
"columnName": "mimeType",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sizeBytes",
"columnName": "sizeBytes",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"messageId",
"partIndex"
]
},
"indices": [
{
"name": "index_attachments_messageId",
"unique": false,
"columnNames": [
"messageId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_attachments_messageId` ON `${TABLE_NAME}` (`messageId`)"
}
],
"foreignKeys": [
{
"table": "messages",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"messageId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "outbox",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `accountId` TEXT NOT NULL, `toAddresses` TEXT NOT NULL, `ccAddresses` TEXT NOT NULL, `bccAddresses` TEXT NOT NULL DEFAULT '', `subject` TEXT NOT NULL, `body` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `lastError` TEXT, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "toAddresses",
"columnName": "toAddresses",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "ccAddresses",
"columnName": "ccAddresses",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "bccAddresses",
"columnName": "bccAddresses",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
},
{
"fieldPath": "subject",
"columnName": "subject",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "body",
"columnName": "body",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastError",
"columnName": "lastError",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "drafts",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `accountId` TEXT, `toAddresses` TEXT NOT NULL, `ccAddresses` TEXT NOT NULL, `bccAddresses` TEXT NOT NULL DEFAULT '', `subject` TEXT NOT NULL, `body` TEXT NOT NULL, `updatedAt` INTEGER NOT NULL, `attachments` TEXT NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "TEXT"
},
{
"fieldPath": "toAddresses",
"columnName": "toAddresses",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "ccAddresses",
"columnName": "ccAddresses",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "bccAddresses",
"columnName": "bccAddresses",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
},
{
"fieldPath": "subject",
"columnName": "subject",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "body",
"columnName": "body",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updatedAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "attachments",
"columnName": "attachments",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "folders",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `fullName` TEXT NOT NULL, `displayName` TEXT NOT NULL, `role` TEXT NOT NULL, `selectable` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL, PRIMARY KEY(`accountId`, `fullName`))",
"fields": [
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "fullName",
"columnName": "fullName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "displayName",
"columnName": "displayName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "role",
"columnName": "role",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "selectable",
"columnName": "selectable",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "sortOrder",
"columnName": "sortOrder",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"accountId",
"fullName"
]
}
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'aff2afe197ff1b0b41630e5a165d09f3')"
]
}
}
@@ -0,0 +1,599 @@
{
"formatVersion": 1,
"database": {
"version": 11,
"identityHash": "a31c7e2934217228a837ab5453025da4",
"entities": [
{
"tableName": "accounts",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `email` TEXT NOT NULL, `displayName` TEXT NOT NULL, `authType` TEXT NOT NULL, `imap_host` TEXT NOT NULL, `imap_port` INTEGER NOT NULL, `imap_security` TEXT NOT NULL, `smtp_host` TEXT NOT NULL, `smtp_port` INTEGER NOT NULL, `smtp_security` TEXT NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "email",
"columnName": "email",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "displayName",
"columnName": "displayName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "authType",
"columnName": "authType",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "imap.host",
"columnName": "imap_host",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "imap.port",
"columnName": "imap_port",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "imap.security",
"columnName": "imap_security",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "smtp.host",
"columnName": "smtp_host",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "smtp.port",
"columnName": "smtp_port",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "smtp.security",
"columnName": "smtp_security",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "account_settings",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `signature` TEXT NOT NULL, `signatureEnabled` INTEGER NOT NULL, `notificationsEnabled` INTEGER NOT NULL, PRIMARY KEY(`accountId`), FOREIGN KEY(`accountId`) REFERENCES `accounts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "signature",
"columnName": "signature",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "signatureEnabled",
"columnName": "signatureEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationsEnabled",
"columnName": "notificationsEnabled",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"accountId"
]
},
"foreignKeys": [
{
"table": "accounts",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"accountId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "messages",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `accountId` TEXT NOT NULL, `sender` TEXT NOT NULL, `senderEmail` TEXT NOT NULL, `subject` TEXT NOT NULL, `snippet` TEXT NOT NULL, `body` TEXT NOT NULL, `isHtml` INTEGER NOT NULL, `timestampMillis` INTEGER NOT NULL, `isRead` INTEGER NOT NULL, `isStarred` INTEGER NOT NULL, `folder` TEXT NOT NULL DEFAULT 'INBOX', `inInbox` INTEGER NOT NULL, `bodyFetched` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sender",
"columnName": "sender",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "senderEmail",
"columnName": "senderEmail",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "subject",
"columnName": "subject",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "snippet",
"columnName": "snippet",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "body",
"columnName": "body",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "isHtml",
"columnName": "isHtml",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "timestampMillis",
"columnName": "timestampMillis",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isRead",
"columnName": "isRead",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isStarred",
"columnName": "isStarred",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "folder",
"columnName": "folder",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "'INBOX'"
},
{
"fieldPath": "inInbox",
"columnName": "inInbox",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "bodyFetched",
"columnName": "bodyFetched",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_messages_accountId",
"unique": false,
"columnNames": [
"accountId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_messages_accountId` ON `${TABLE_NAME}` (`accountId`)"
},
{
"name": "index_messages_timestampMillis",
"unique": false,
"columnNames": [
"timestampMillis"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_messages_timestampMillis` ON `${TABLE_NAME}` (`timestampMillis`)"
}
]
},
{
"tableName": "credentials",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `encryptedSecret` TEXT NOT NULL, PRIMARY KEY(`accountId`))",
"fields": [
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "encryptedSecret",
"columnName": "encryptedSecret",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"accountId"
]
}
},
{
"tableName": "attachments",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`messageId` TEXT NOT NULL, `partIndex` INTEGER NOT NULL, `filename` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `sizeBytes` INTEGER NOT NULL, PRIMARY KEY(`messageId`, `partIndex`), FOREIGN KEY(`messageId`) REFERENCES `messages`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "messageId",
"columnName": "messageId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "partIndex",
"columnName": "partIndex",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "filename",
"columnName": "filename",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "mimeType",
"columnName": "mimeType",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sizeBytes",
"columnName": "sizeBytes",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"messageId",
"partIndex"
]
},
"indices": [
{
"name": "index_attachments_messageId",
"unique": false,
"columnNames": [
"messageId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_attachments_messageId` ON `${TABLE_NAME}` (`messageId`)"
}
],
"foreignKeys": [
{
"table": "messages",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"messageId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "outbox",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `accountId` TEXT NOT NULL, `toAddresses` TEXT NOT NULL, `ccAddresses` TEXT NOT NULL, `bccAddresses` TEXT NOT NULL DEFAULT '', `subject` TEXT NOT NULL, `body` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `lastError` TEXT, `bodyHtml` TEXT, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "toAddresses",
"columnName": "toAddresses",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "ccAddresses",
"columnName": "ccAddresses",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "bccAddresses",
"columnName": "bccAddresses",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
},
{
"fieldPath": "subject",
"columnName": "subject",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "body",
"columnName": "body",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastError",
"columnName": "lastError",
"affinity": "TEXT"
},
{
"fieldPath": "bodyHtml",
"columnName": "bodyHtml",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "drafts",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `accountId` TEXT, `toAddresses` TEXT NOT NULL, `ccAddresses` TEXT NOT NULL, `bccAddresses` TEXT NOT NULL DEFAULT '', `subject` TEXT NOT NULL, `body` TEXT NOT NULL, `updatedAt` INTEGER NOT NULL, `attachments` TEXT NOT NULL, `bodyHtml` TEXT, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "TEXT"
},
{
"fieldPath": "toAddresses",
"columnName": "toAddresses",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "ccAddresses",
"columnName": "ccAddresses",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "bccAddresses",
"columnName": "bccAddresses",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
},
{
"fieldPath": "subject",
"columnName": "subject",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "body",
"columnName": "body",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updatedAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "attachments",
"columnName": "attachments",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "bodyHtml",
"columnName": "bodyHtml",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "folders",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `fullName` TEXT NOT NULL, `displayName` TEXT NOT NULL, `role` TEXT NOT NULL, `selectable` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL, PRIMARY KEY(`accountId`, `fullName`))",
"fields": [
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "fullName",
"columnName": "fullName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "displayName",
"columnName": "displayName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "role",
"columnName": "role",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "selectable",
"columnName": "selectable",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "sortOrder",
"columnName": "sortOrder",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"accountId",
"fullName"
]
}
},
{
"tableName": "signatures",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `accountId` TEXT NOT NULL, `name` TEXT NOT NULL, `contentHtml` TEXT NOT NULL, `isDefault` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`accountId`) REFERENCES `accounts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "contentHtml",
"columnName": "contentHtml",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "isDefault",
"columnName": "isDefault",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_signatures_accountId",
"unique": false,
"columnNames": [
"accountId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_signatures_accountId` ON `${TABLE_NAME}` (`accountId`)"
}
],
"foreignKeys": [
{
"table": "accounts",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"accountId"
],
"referencedColumns": [
"id"
]
}
]
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'a31c7e2934217228a837ab5453025da4')"
]
}
}
@@ -0,0 +1,606 @@
{
"formatVersion": 1,
"database": {
"version": 12,
"identityHash": "45329c820e9325adbfd6b7de89a9996a",
"entities": [
{
"tableName": "accounts",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `email` TEXT NOT NULL, `displayName` TEXT NOT NULL, `authType` TEXT NOT NULL, `imap_host` TEXT NOT NULL, `imap_port` INTEGER NOT NULL, `imap_security` TEXT NOT NULL, `smtp_host` TEXT NOT NULL, `smtp_port` INTEGER NOT NULL, `smtp_security` TEXT NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "email",
"columnName": "email",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "displayName",
"columnName": "displayName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "authType",
"columnName": "authType",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "imap.host",
"columnName": "imap_host",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "imap.port",
"columnName": "imap_port",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "imap.security",
"columnName": "imap_security",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "smtp.host",
"columnName": "smtp_host",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "smtp.port",
"columnName": "smtp_port",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "smtp.security",
"columnName": "smtp_security",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "account_settings",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `signature` TEXT NOT NULL, `signatureEnabled` INTEGER NOT NULL, `notificationsEnabled` INTEGER NOT NULL, PRIMARY KEY(`accountId`), FOREIGN KEY(`accountId`) REFERENCES `accounts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "signature",
"columnName": "signature",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "signatureEnabled",
"columnName": "signatureEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationsEnabled",
"columnName": "notificationsEnabled",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"accountId"
]
},
"foreignKeys": [
{
"table": "accounts",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"accountId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "messages",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `accountId` TEXT NOT NULL, `sender` TEXT NOT NULL, `senderEmail` TEXT NOT NULL, `subject` TEXT NOT NULL, `snippet` TEXT NOT NULL, `body` TEXT NOT NULL, `isHtml` INTEGER NOT NULL, `timestampMillis` INTEGER NOT NULL, `isRead` INTEGER NOT NULL, `isStarred` INTEGER NOT NULL, `folder` TEXT NOT NULL DEFAULT 'INBOX', `inInbox` INTEGER NOT NULL, `bodyFetched` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sender",
"columnName": "sender",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "senderEmail",
"columnName": "senderEmail",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "subject",
"columnName": "subject",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "snippet",
"columnName": "snippet",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "body",
"columnName": "body",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "isHtml",
"columnName": "isHtml",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "timestampMillis",
"columnName": "timestampMillis",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isRead",
"columnName": "isRead",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isStarred",
"columnName": "isStarred",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "folder",
"columnName": "folder",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "'INBOX'"
},
{
"fieldPath": "inInbox",
"columnName": "inInbox",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "bodyFetched",
"columnName": "bodyFetched",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_messages_accountId",
"unique": false,
"columnNames": [
"accountId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_messages_accountId` ON `${TABLE_NAME}` (`accountId`)"
},
{
"name": "index_messages_timestampMillis",
"unique": false,
"columnNames": [
"timestampMillis"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_messages_timestampMillis` ON `${TABLE_NAME}` (`timestampMillis`)"
}
]
},
{
"tableName": "credentials",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `encryptedSecret` TEXT NOT NULL, PRIMARY KEY(`accountId`))",
"fields": [
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "encryptedSecret",
"columnName": "encryptedSecret",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"accountId"
]
}
},
{
"tableName": "attachments",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`messageId` TEXT NOT NULL, `partIndex` INTEGER NOT NULL, `filename` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `sizeBytes` INTEGER NOT NULL, PRIMARY KEY(`messageId`, `partIndex`), FOREIGN KEY(`messageId`) REFERENCES `messages`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "messageId",
"columnName": "messageId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "partIndex",
"columnName": "partIndex",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "filename",
"columnName": "filename",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "mimeType",
"columnName": "mimeType",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sizeBytes",
"columnName": "sizeBytes",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"messageId",
"partIndex"
]
},
"indices": [
{
"name": "index_attachments_messageId",
"unique": false,
"columnNames": [
"messageId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_attachments_messageId` ON `${TABLE_NAME}` (`messageId`)"
}
],
"foreignKeys": [
{
"table": "messages",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"messageId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "outbox",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `accountId` TEXT NOT NULL, `toAddresses` TEXT NOT NULL, `ccAddresses` TEXT NOT NULL, `bccAddresses` TEXT NOT NULL DEFAULT '', `subject` TEXT NOT NULL, `body` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `lastError` TEXT, `bodyHtml` TEXT, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "toAddresses",
"columnName": "toAddresses",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "ccAddresses",
"columnName": "ccAddresses",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "bccAddresses",
"columnName": "bccAddresses",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
},
{
"fieldPath": "subject",
"columnName": "subject",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "body",
"columnName": "body",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastError",
"columnName": "lastError",
"affinity": "TEXT"
},
{
"fieldPath": "bodyHtml",
"columnName": "bodyHtml",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "drafts",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `accountId` TEXT, `toAddresses` TEXT NOT NULL, `ccAddresses` TEXT NOT NULL, `bccAddresses` TEXT NOT NULL DEFAULT '', `subject` TEXT NOT NULL, `body` TEXT NOT NULL, `updatedAt` INTEGER NOT NULL, `attachments` TEXT NOT NULL, `bodyHtml` TEXT, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "TEXT"
},
{
"fieldPath": "toAddresses",
"columnName": "toAddresses",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "ccAddresses",
"columnName": "ccAddresses",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "bccAddresses",
"columnName": "bccAddresses",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
},
{
"fieldPath": "subject",
"columnName": "subject",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "body",
"columnName": "body",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updatedAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "attachments",
"columnName": "attachments",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "bodyHtml",
"columnName": "bodyHtml",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "folders",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `fullName` TEXT NOT NULL, `displayName` TEXT NOT NULL, `role` TEXT NOT NULL, `selectable` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL, `specialUse` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`accountId`, `fullName`))",
"fields": [
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "fullName",
"columnName": "fullName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "displayName",
"columnName": "displayName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "role",
"columnName": "role",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "selectable",
"columnName": "selectable",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "sortOrder",
"columnName": "sortOrder",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "specialUse",
"columnName": "specialUse",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"accountId",
"fullName"
]
}
},
{
"tableName": "signatures",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `accountId` TEXT NOT NULL, `name` TEXT NOT NULL, `contentHtml` TEXT NOT NULL, `isDefault` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`accountId`) REFERENCES `accounts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "contentHtml",
"columnName": "contentHtml",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "isDefault",
"columnName": "isDefault",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_signatures_accountId",
"unique": false,
"columnNames": [
"accountId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_signatures_accountId` ON `${TABLE_NAME}` (`accountId`)"
}
],
"foreignKeys": [
{
"table": "accounts",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"accountId"
],
"referencedColumns": [
"id"
]
}
]
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '45329c820e9325adbfd6b7de89a9996a')"
]
}
}
@@ -0,0 +1,671 @@
{
"formatVersion": 1,
"database": {
"version": 13,
"identityHash": "e4f7ef1e0d780324d6eec3efced768ae",
"entities": [
{
"tableName": "accounts",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `email` TEXT NOT NULL, `displayName` TEXT NOT NULL, `authType` TEXT NOT NULL, `imap_host` TEXT NOT NULL, `imap_port` INTEGER NOT NULL, `imap_security` TEXT NOT NULL, `smtp_host` TEXT NOT NULL, `smtp_port` INTEGER NOT NULL, `smtp_security` TEXT NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "email",
"columnName": "email",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "displayName",
"columnName": "displayName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "authType",
"columnName": "authType",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "imap.host",
"columnName": "imap_host",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "imap.port",
"columnName": "imap_port",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "imap.security",
"columnName": "imap_security",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "smtp.host",
"columnName": "smtp_host",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "smtp.port",
"columnName": "smtp_port",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "smtp.security",
"columnName": "smtp_security",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "account_settings",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `signature` TEXT NOT NULL, `signatureEnabled` INTEGER NOT NULL, `notificationsEnabled` INTEGER NOT NULL, `retentionCount` INTEGER, `retentionMonths` INTEGER, PRIMARY KEY(`accountId`), FOREIGN KEY(`accountId`) REFERENCES `accounts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "signature",
"columnName": "signature",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "signatureEnabled",
"columnName": "signatureEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationsEnabled",
"columnName": "notificationsEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "retentionCount",
"columnName": "retentionCount",
"affinity": "INTEGER"
},
{
"fieldPath": "retentionMonths",
"columnName": "retentionMonths",
"affinity": "INTEGER"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"accountId"
]
},
"foreignKeys": [
{
"table": "accounts",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"accountId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "messages",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `accountId` TEXT NOT NULL, `sender` TEXT NOT NULL, `senderEmail` TEXT NOT NULL, `subject` TEXT NOT NULL, `snippet` TEXT NOT NULL, `body` TEXT NOT NULL, `isHtml` INTEGER NOT NULL, `timestampMillis` INTEGER NOT NULL, `isRead` INTEGER NOT NULL, `isStarred` INTEGER NOT NULL, `folder` TEXT NOT NULL DEFAULT 'INBOX', `inInbox` INTEGER NOT NULL, `bodyFetched` INTEGER NOT NULL, `uid` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sender",
"columnName": "sender",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "senderEmail",
"columnName": "senderEmail",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "subject",
"columnName": "subject",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "snippet",
"columnName": "snippet",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "body",
"columnName": "body",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "isHtml",
"columnName": "isHtml",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "timestampMillis",
"columnName": "timestampMillis",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isRead",
"columnName": "isRead",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isStarred",
"columnName": "isStarred",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "folder",
"columnName": "folder",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "'INBOX'"
},
{
"fieldPath": "inInbox",
"columnName": "inInbox",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "bodyFetched",
"columnName": "bodyFetched",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "uid",
"columnName": "uid",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_messages_accountId",
"unique": false,
"columnNames": [
"accountId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_messages_accountId` ON `${TABLE_NAME}` (`accountId`)"
},
{
"name": "index_messages_timestampMillis",
"unique": false,
"columnNames": [
"timestampMillis"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_messages_timestampMillis` ON `${TABLE_NAME}` (`timestampMillis`)"
},
{
"name": "index_messages_accountId_folder_uid",
"unique": false,
"columnNames": [
"accountId",
"folder",
"uid"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_messages_accountId_folder_uid` ON `${TABLE_NAME}` (`accountId`, `folder`, `uid`)"
}
]
},
{
"tableName": "credentials",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `encryptedSecret` TEXT NOT NULL, PRIMARY KEY(`accountId`))",
"fields": [
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "encryptedSecret",
"columnName": "encryptedSecret",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"accountId"
]
}
},
{
"tableName": "attachments",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`messageId` TEXT NOT NULL, `partIndex` INTEGER NOT NULL, `filename` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `sizeBytes` INTEGER NOT NULL, PRIMARY KEY(`messageId`, `partIndex`), FOREIGN KEY(`messageId`) REFERENCES `messages`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "messageId",
"columnName": "messageId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "partIndex",
"columnName": "partIndex",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "filename",
"columnName": "filename",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "mimeType",
"columnName": "mimeType",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sizeBytes",
"columnName": "sizeBytes",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"messageId",
"partIndex"
]
},
"indices": [
{
"name": "index_attachments_messageId",
"unique": false,
"columnNames": [
"messageId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_attachments_messageId` ON `${TABLE_NAME}` (`messageId`)"
}
],
"foreignKeys": [
{
"table": "messages",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"messageId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "outbox",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `accountId` TEXT NOT NULL, `toAddresses` TEXT NOT NULL, `ccAddresses` TEXT NOT NULL, `bccAddresses` TEXT NOT NULL DEFAULT '', `subject` TEXT NOT NULL, `body` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `lastError` TEXT, `bodyHtml` TEXT, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "toAddresses",
"columnName": "toAddresses",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "ccAddresses",
"columnName": "ccAddresses",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "bccAddresses",
"columnName": "bccAddresses",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
},
{
"fieldPath": "subject",
"columnName": "subject",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "body",
"columnName": "body",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastError",
"columnName": "lastError",
"affinity": "TEXT"
},
{
"fieldPath": "bodyHtml",
"columnName": "bodyHtml",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "drafts",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `accountId` TEXT, `toAddresses` TEXT NOT NULL, `ccAddresses` TEXT NOT NULL, `bccAddresses` TEXT NOT NULL DEFAULT '', `subject` TEXT NOT NULL, `body` TEXT NOT NULL, `updatedAt` INTEGER NOT NULL, `attachments` TEXT NOT NULL, `bodyHtml` TEXT, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "TEXT"
},
{
"fieldPath": "toAddresses",
"columnName": "toAddresses",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "ccAddresses",
"columnName": "ccAddresses",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "bccAddresses",
"columnName": "bccAddresses",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
},
{
"fieldPath": "subject",
"columnName": "subject",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "body",
"columnName": "body",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updatedAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "attachments",
"columnName": "attachments",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "bodyHtml",
"columnName": "bodyHtml",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "folders",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `fullName` TEXT NOT NULL, `displayName` TEXT NOT NULL, `role` TEXT NOT NULL, `selectable` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL, `specialUse` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`accountId`, `fullName`))",
"fields": [
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "fullName",
"columnName": "fullName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "displayName",
"columnName": "displayName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "role",
"columnName": "role",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "selectable",
"columnName": "selectable",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "sortOrder",
"columnName": "sortOrder",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "specialUse",
"columnName": "specialUse",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"accountId",
"fullName"
]
}
},
{
"tableName": "signatures",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `accountId` TEXT NOT NULL, `name` TEXT NOT NULL, `contentHtml` TEXT NOT NULL, `isDefault` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`accountId`) REFERENCES `accounts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "contentHtml",
"columnName": "contentHtml",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "isDefault",
"columnName": "isDefault",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_signatures_accountId",
"unique": false,
"columnNames": [
"accountId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_signatures_accountId` ON `${TABLE_NAME}` (`accountId`)"
}
],
"foreignKeys": [
{
"table": "accounts",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"accountId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "backfill_progress",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `folder` TEXT NOT NULL, `nextBeforeUid` INTEGER NOT NULL, `complete` INTEGER NOT NULL, PRIMARY KEY(`accountId`, `folder`))",
"fields": [
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "folder",
"columnName": "folder",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "nextBeforeUid",
"columnName": "nextBeforeUid",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "complete",
"columnName": "complete",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"accountId",
"folder"
]
}
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'e4f7ef1e0d780324d6eec3efced768ae')"
]
}
}
@@ -0,0 +1,671 @@
{
"formatVersion": 1,
"database": {
"version": 14,
"identityHash": "e4f7ef1e0d780324d6eec3efced768ae",
"entities": [
{
"tableName": "accounts",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `email` TEXT NOT NULL, `displayName` TEXT NOT NULL, `authType` TEXT NOT NULL, `imap_host` TEXT NOT NULL, `imap_port` INTEGER NOT NULL, `imap_security` TEXT NOT NULL, `smtp_host` TEXT NOT NULL, `smtp_port` INTEGER NOT NULL, `smtp_security` TEXT NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "email",
"columnName": "email",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "displayName",
"columnName": "displayName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "authType",
"columnName": "authType",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "imap.host",
"columnName": "imap_host",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "imap.port",
"columnName": "imap_port",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "imap.security",
"columnName": "imap_security",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "smtp.host",
"columnName": "smtp_host",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "smtp.port",
"columnName": "smtp_port",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "smtp.security",
"columnName": "smtp_security",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "account_settings",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `signature` TEXT NOT NULL, `signatureEnabled` INTEGER NOT NULL, `notificationsEnabled` INTEGER NOT NULL, `retentionCount` INTEGER, `retentionMonths` INTEGER, PRIMARY KEY(`accountId`), FOREIGN KEY(`accountId`) REFERENCES `accounts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "signature",
"columnName": "signature",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "signatureEnabled",
"columnName": "signatureEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationsEnabled",
"columnName": "notificationsEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "retentionCount",
"columnName": "retentionCount",
"affinity": "INTEGER"
},
{
"fieldPath": "retentionMonths",
"columnName": "retentionMonths",
"affinity": "INTEGER"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"accountId"
]
},
"foreignKeys": [
{
"table": "accounts",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"accountId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "messages",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `accountId` TEXT NOT NULL, `sender` TEXT NOT NULL, `senderEmail` TEXT NOT NULL, `subject` TEXT NOT NULL, `snippet` TEXT NOT NULL, `body` TEXT NOT NULL, `isHtml` INTEGER NOT NULL, `timestampMillis` INTEGER NOT NULL, `isRead` INTEGER NOT NULL, `isStarred` INTEGER NOT NULL, `folder` TEXT NOT NULL DEFAULT 'INBOX', `inInbox` INTEGER NOT NULL, `bodyFetched` INTEGER NOT NULL, `uid` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sender",
"columnName": "sender",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "senderEmail",
"columnName": "senderEmail",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "subject",
"columnName": "subject",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "snippet",
"columnName": "snippet",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "body",
"columnName": "body",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "isHtml",
"columnName": "isHtml",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "timestampMillis",
"columnName": "timestampMillis",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isRead",
"columnName": "isRead",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isStarred",
"columnName": "isStarred",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "folder",
"columnName": "folder",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "'INBOX'"
},
{
"fieldPath": "inInbox",
"columnName": "inInbox",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "bodyFetched",
"columnName": "bodyFetched",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "uid",
"columnName": "uid",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_messages_accountId",
"unique": false,
"columnNames": [
"accountId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_messages_accountId` ON `${TABLE_NAME}` (`accountId`)"
},
{
"name": "index_messages_timestampMillis",
"unique": false,
"columnNames": [
"timestampMillis"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_messages_timestampMillis` ON `${TABLE_NAME}` (`timestampMillis`)"
},
{
"name": "index_messages_accountId_folder_uid",
"unique": false,
"columnNames": [
"accountId",
"folder",
"uid"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_messages_accountId_folder_uid` ON `${TABLE_NAME}` (`accountId`, `folder`, `uid`)"
}
]
},
{
"tableName": "credentials",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `encryptedSecret` TEXT NOT NULL, PRIMARY KEY(`accountId`))",
"fields": [
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "encryptedSecret",
"columnName": "encryptedSecret",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"accountId"
]
}
},
{
"tableName": "attachments",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`messageId` TEXT NOT NULL, `partIndex` INTEGER NOT NULL, `filename` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `sizeBytes` INTEGER NOT NULL, PRIMARY KEY(`messageId`, `partIndex`), FOREIGN KEY(`messageId`) REFERENCES `messages`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "messageId",
"columnName": "messageId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "partIndex",
"columnName": "partIndex",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "filename",
"columnName": "filename",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "mimeType",
"columnName": "mimeType",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sizeBytes",
"columnName": "sizeBytes",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"messageId",
"partIndex"
]
},
"indices": [
{
"name": "index_attachments_messageId",
"unique": false,
"columnNames": [
"messageId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_attachments_messageId` ON `${TABLE_NAME}` (`messageId`)"
}
],
"foreignKeys": [
{
"table": "messages",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"messageId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "outbox",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `accountId` TEXT NOT NULL, `toAddresses` TEXT NOT NULL, `ccAddresses` TEXT NOT NULL, `bccAddresses` TEXT NOT NULL DEFAULT '', `subject` TEXT NOT NULL, `body` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `lastError` TEXT, `bodyHtml` TEXT, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "toAddresses",
"columnName": "toAddresses",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "ccAddresses",
"columnName": "ccAddresses",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "bccAddresses",
"columnName": "bccAddresses",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
},
{
"fieldPath": "subject",
"columnName": "subject",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "body",
"columnName": "body",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastError",
"columnName": "lastError",
"affinity": "TEXT"
},
{
"fieldPath": "bodyHtml",
"columnName": "bodyHtml",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "drafts",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `accountId` TEXT, `toAddresses` TEXT NOT NULL, `ccAddresses` TEXT NOT NULL, `bccAddresses` TEXT NOT NULL DEFAULT '', `subject` TEXT NOT NULL, `body` TEXT NOT NULL, `updatedAt` INTEGER NOT NULL, `attachments` TEXT NOT NULL, `bodyHtml` TEXT, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "TEXT"
},
{
"fieldPath": "toAddresses",
"columnName": "toAddresses",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "ccAddresses",
"columnName": "ccAddresses",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "bccAddresses",
"columnName": "bccAddresses",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
},
{
"fieldPath": "subject",
"columnName": "subject",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "body",
"columnName": "body",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updatedAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "attachments",
"columnName": "attachments",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "bodyHtml",
"columnName": "bodyHtml",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "folders",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `fullName` TEXT NOT NULL, `displayName` TEXT NOT NULL, `role` TEXT NOT NULL, `selectable` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL, `specialUse` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`accountId`, `fullName`))",
"fields": [
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "fullName",
"columnName": "fullName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "displayName",
"columnName": "displayName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "role",
"columnName": "role",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "selectable",
"columnName": "selectable",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "sortOrder",
"columnName": "sortOrder",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "specialUse",
"columnName": "specialUse",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"accountId",
"fullName"
]
}
},
{
"tableName": "signatures",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `accountId` TEXT NOT NULL, `name` TEXT NOT NULL, `contentHtml` TEXT NOT NULL, `isDefault` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`accountId`) REFERENCES `accounts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "contentHtml",
"columnName": "contentHtml",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "isDefault",
"columnName": "isDefault",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_signatures_accountId",
"unique": false,
"columnNames": [
"accountId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_signatures_accountId` ON `${TABLE_NAME}` (`accountId`)"
}
],
"foreignKeys": [
{
"table": "accounts",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"accountId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "backfill_progress",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `folder` TEXT NOT NULL, `nextBeforeUid` INTEGER NOT NULL, `complete` INTEGER NOT NULL, PRIMARY KEY(`accountId`, `folder`))",
"fields": [
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "folder",
"columnName": "folder",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "nextBeforeUid",
"columnName": "nextBeforeUid",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "complete",
"columnName": "complete",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"accountId",
"folder"
]
}
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'e4f7ef1e0d780324d6eec3efced768ae')"
]
}
}
@@ -0,0 +1,676 @@
{
"formatVersion": 1,
"database": {
"version": 15,
"identityHash": "02427d9d822cbb18a9bcd0670c048585",
"entities": [
{
"tableName": "accounts",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `email` TEXT NOT NULL, `displayName` TEXT NOT NULL, `authType` TEXT NOT NULL, `imap_host` TEXT NOT NULL, `imap_port` INTEGER NOT NULL, `imap_security` TEXT NOT NULL, `smtp_host` TEXT NOT NULL, `smtp_port` INTEGER NOT NULL, `smtp_security` TEXT NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "email",
"columnName": "email",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "displayName",
"columnName": "displayName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "authType",
"columnName": "authType",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "imap.host",
"columnName": "imap_host",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "imap.port",
"columnName": "imap_port",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "imap.security",
"columnName": "imap_security",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "smtp.host",
"columnName": "smtp_host",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "smtp.port",
"columnName": "smtp_port",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "smtp.security",
"columnName": "smtp_security",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "account_settings",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `signature` TEXT NOT NULL, `signatureEnabled` INTEGER NOT NULL, `notificationsEnabled` INTEGER NOT NULL, `retentionCount` INTEGER, `retentionMonths` INTEGER, PRIMARY KEY(`accountId`), FOREIGN KEY(`accountId`) REFERENCES `accounts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "signature",
"columnName": "signature",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "signatureEnabled",
"columnName": "signatureEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationsEnabled",
"columnName": "notificationsEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "retentionCount",
"columnName": "retentionCount",
"affinity": "INTEGER"
},
{
"fieldPath": "retentionMonths",
"columnName": "retentionMonths",
"affinity": "INTEGER"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"accountId"
]
},
"foreignKeys": [
{
"table": "accounts",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"accountId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "messages",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `accountId` TEXT NOT NULL, `sender` TEXT NOT NULL, `senderEmail` TEXT NOT NULL, `subject` TEXT NOT NULL, `snippet` TEXT NOT NULL, `body` TEXT NOT NULL, `isHtml` INTEGER NOT NULL, `timestampMillis` INTEGER NOT NULL, `isRead` INTEGER NOT NULL, `isStarred` INTEGER NOT NULL, `folder` TEXT NOT NULL DEFAULT 'INBOX', `inInbox` INTEGER NOT NULL, `bodyFetched` INTEGER NOT NULL, `uid` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sender",
"columnName": "sender",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "senderEmail",
"columnName": "senderEmail",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "subject",
"columnName": "subject",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "snippet",
"columnName": "snippet",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "body",
"columnName": "body",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "isHtml",
"columnName": "isHtml",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "timestampMillis",
"columnName": "timestampMillis",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isRead",
"columnName": "isRead",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isStarred",
"columnName": "isStarred",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "folder",
"columnName": "folder",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "'INBOX'"
},
{
"fieldPath": "inInbox",
"columnName": "inInbox",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "bodyFetched",
"columnName": "bodyFetched",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "uid",
"columnName": "uid",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_messages_accountId",
"unique": false,
"columnNames": [
"accountId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_messages_accountId` ON `${TABLE_NAME}` (`accountId`)"
},
{
"name": "index_messages_timestampMillis",
"unique": false,
"columnNames": [
"timestampMillis"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_messages_timestampMillis` ON `${TABLE_NAME}` (`timestampMillis`)"
},
{
"name": "index_messages_accountId_folder_uid",
"unique": false,
"columnNames": [
"accountId",
"folder",
"uid"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_messages_accountId_folder_uid` ON `${TABLE_NAME}` (`accountId`, `folder`, `uid`)"
}
]
},
{
"tableName": "credentials",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `encryptedSecret` TEXT NOT NULL, PRIMARY KEY(`accountId`))",
"fields": [
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "encryptedSecret",
"columnName": "encryptedSecret",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"accountId"
]
}
},
{
"tableName": "attachments",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`messageId` TEXT NOT NULL, `partIndex` INTEGER NOT NULL, `filename` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `sizeBytes` INTEGER NOT NULL, PRIMARY KEY(`messageId`, `partIndex`), FOREIGN KEY(`messageId`) REFERENCES `messages`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "messageId",
"columnName": "messageId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "partIndex",
"columnName": "partIndex",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "filename",
"columnName": "filename",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "mimeType",
"columnName": "mimeType",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sizeBytes",
"columnName": "sizeBytes",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"messageId",
"partIndex"
]
},
"indices": [
{
"name": "index_attachments_messageId",
"unique": false,
"columnNames": [
"messageId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_attachments_messageId` ON `${TABLE_NAME}` (`messageId`)"
}
],
"foreignKeys": [
{
"table": "messages",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"messageId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "outbox",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `accountId` TEXT NOT NULL, `toAddresses` TEXT NOT NULL, `ccAddresses` TEXT NOT NULL, `bccAddresses` TEXT NOT NULL DEFAULT '', `subject` TEXT NOT NULL, `body` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `lastError` TEXT, `bodyHtml` TEXT, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "toAddresses",
"columnName": "toAddresses",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "ccAddresses",
"columnName": "ccAddresses",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "bccAddresses",
"columnName": "bccAddresses",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
},
{
"fieldPath": "subject",
"columnName": "subject",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "body",
"columnName": "body",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastError",
"columnName": "lastError",
"affinity": "TEXT"
},
{
"fieldPath": "bodyHtml",
"columnName": "bodyHtml",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "drafts",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `accountId` TEXT, `toAddresses` TEXT NOT NULL, `ccAddresses` TEXT NOT NULL, `bccAddresses` TEXT NOT NULL DEFAULT '', `subject` TEXT NOT NULL, `body` TEXT NOT NULL, `updatedAt` INTEGER NOT NULL, `attachments` TEXT NOT NULL, `bodyHtml` TEXT, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "TEXT"
},
{
"fieldPath": "toAddresses",
"columnName": "toAddresses",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "ccAddresses",
"columnName": "ccAddresses",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "bccAddresses",
"columnName": "bccAddresses",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
},
{
"fieldPath": "subject",
"columnName": "subject",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "body",
"columnName": "body",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updatedAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "attachments",
"columnName": "attachments",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "bodyHtml",
"columnName": "bodyHtml",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "folders",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `fullName` TEXT NOT NULL, `displayName` TEXT NOT NULL, `role` TEXT NOT NULL, `selectable` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL, `specialUse` INTEGER NOT NULL DEFAULT 0, `hierarchyDelimiter` TEXT, PRIMARY KEY(`accountId`, `fullName`))",
"fields": [
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "fullName",
"columnName": "fullName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "displayName",
"columnName": "displayName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "role",
"columnName": "role",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "selectable",
"columnName": "selectable",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "sortOrder",
"columnName": "sortOrder",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "specialUse",
"columnName": "specialUse",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "hierarchyDelimiter",
"columnName": "hierarchyDelimiter",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"accountId",
"fullName"
]
}
},
{
"tableName": "signatures",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `accountId` TEXT NOT NULL, `name` TEXT NOT NULL, `contentHtml` TEXT NOT NULL, `isDefault` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`accountId`) REFERENCES `accounts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "contentHtml",
"columnName": "contentHtml",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "isDefault",
"columnName": "isDefault",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_signatures_accountId",
"unique": false,
"columnNames": [
"accountId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_signatures_accountId` ON `${TABLE_NAME}` (`accountId`)"
}
],
"foreignKeys": [
{
"table": "accounts",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"accountId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "backfill_progress",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `folder` TEXT NOT NULL, `nextBeforeUid` INTEGER NOT NULL, `complete` INTEGER NOT NULL, PRIMARY KEY(`accountId`, `folder`))",
"fields": [
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "folder",
"columnName": "folder",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "nextBeforeUid",
"columnName": "nextBeforeUid",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "complete",
"columnName": "complete",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"accountId",
"folder"
]
}
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '02427d9d822cbb18a9bcd0670c048585')"
]
}
}
@@ -0,0 +1,455 @@
{
"formatVersion": 1,
"database": {
"version": 16,
"identityHash": "b5c1a38d197cf1335d3092e413d55d0d",
"entities": [
{
"tableName": "messages",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `accountId` TEXT NOT NULL, `sender` TEXT NOT NULL, `senderEmail` TEXT NOT NULL, `subject` TEXT NOT NULL, `snippet` TEXT NOT NULL, `body` TEXT NOT NULL, `isHtml` INTEGER NOT NULL, `timestampMillis` INTEGER NOT NULL, `isRead` INTEGER NOT NULL, `isStarred` INTEGER NOT NULL, `folder` TEXT NOT NULL DEFAULT 'INBOX', `inInbox` INTEGER NOT NULL, `bodyFetched` INTEGER NOT NULL, `uid` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sender",
"columnName": "sender",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "senderEmail",
"columnName": "senderEmail",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "subject",
"columnName": "subject",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "snippet",
"columnName": "snippet",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "body",
"columnName": "body",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "isHtml",
"columnName": "isHtml",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "timestampMillis",
"columnName": "timestampMillis",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isRead",
"columnName": "isRead",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isStarred",
"columnName": "isStarred",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "folder",
"columnName": "folder",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "'INBOX'"
},
{
"fieldPath": "inInbox",
"columnName": "inInbox",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "bodyFetched",
"columnName": "bodyFetched",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "uid",
"columnName": "uid",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_messages_accountId",
"unique": false,
"columnNames": [
"accountId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_messages_accountId` ON `${TABLE_NAME}` (`accountId`)"
},
{
"name": "index_messages_timestampMillis",
"unique": false,
"columnNames": [
"timestampMillis"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_messages_timestampMillis` ON `${TABLE_NAME}` (`timestampMillis`)"
},
{
"name": "index_messages_accountId_folder_uid",
"unique": false,
"columnNames": [
"accountId",
"folder",
"uid"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_messages_accountId_folder_uid` ON `${TABLE_NAME}` (`accountId`, `folder`, `uid`)"
}
]
},
{
"tableName": "attachments",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`messageId` TEXT NOT NULL, `partIndex` INTEGER NOT NULL, `filename` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `sizeBytes` INTEGER NOT NULL, PRIMARY KEY(`messageId`, `partIndex`), FOREIGN KEY(`messageId`) REFERENCES `messages`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "messageId",
"columnName": "messageId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "partIndex",
"columnName": "partIndex",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "filename",
"columnName": "filename",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "mimeType",
"columnName": "mimeType",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sizeBytes",
"columnName": "sizeBytes",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"messageId",
"partIndex"
]
},
"indices": [
{
"name": "index_attachments_messageId",
"unique": false,
"columnNames": [
"messageId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_attachments_messageId` ON `${TABLE_NAME}` (`messageId`)"
}
],
"foreignKeys": [
{
"table": "messages",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"messageId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "outbox",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `accountId` TEXT NOT NULL, `toAddresses` TEXT NOT NULL, `ccAddresses` TEXT NOT NULL, `bccAddresses` TEXT NOT NULL DEFAULT '', `subject` TEXT NOT NULL, `body` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `lastError` TEXT, `bodyHtml` TEXT, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "toAddresses",
"columnName": "toAddresses",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "ccAddresses",
"columnName": "ccAddresses",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "bccAddresses",
"columnName": "bccAddresses",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
},
{
"fieldPath": "subject",
"columnName": "subject",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "body",
"columnName": "body",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastError",
"columnName": "lastError",
"affinity": "TEXT"
},
{
"fieldPath": "bodyHtml",
"columnName": "bodyHtml",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "drafts",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `accountId` TEXT, `toAddresses` TEXT NOT NULL, `ccAddresses` TEXT NOT NULL, `bccAddresses` TEXT NOT NULL DEFAULT '', `subject` TEXT NOT NULL, `body` TEXT NOT NULL, `updatedAt` INTEGER NOT NULL, `attachments` TEXT NOT NULL, `bodyHtml` TEXT, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "TEXT"
},
{
"fieldPath": "toAddresses",
"columnName": "toAddresses",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "ccAddresses",
"columnName": "ccAddresses",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "bccAddresses",
"columnName": "bccAddresses",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
},
{
"fieldPath": "subject",
"columnName": "subject",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "body",
"columnName": "body",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updatedAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "attachments",
"columnName": "attachments",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "bodyHtml",
"columnName": "bodyHtml",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "folders",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `fullName` TEXT NOT NULL, `displayName` TEXT NOT NULL, `role` TEXT NOT NULL, `selectable` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL, `specialUse` INTEGER NOT NULL DEFAULT 0, `hierarchyDelimiter` TEXT, PRIMARY KEY(`accountId`, `fullName`))",
"fields": [
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "fullName",
"columnName": "fullName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "displayName",
"columnName": "displayName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "role",
"columnName": "role",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "selectable",
"columnName": "selectable",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "sortOrder",
"columnName": "sortOrder",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "specialUse",
"columnName": "specialUse",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "hierarchyDelimiter",
"columnName": "hierarchyDelimiter",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"accountId",
"fullName"
]
}
},
{
"tableName": "backfill_progress",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `folder` TEXT NOT NULL, `nextBeforeUid` INTEGER NOT NULL, `complete` INTEGER NOT NULL, PRIMARY KEY(`accountId`, `folder`))",
"fields": [
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "folder",
"columnName": "folder",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "nextBeforeUid",
"columnName": "nextBeforeUid",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "complete",
"columnName": "complete",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"accountId",
"folder"
]
}
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'b5c1a38d197cf1335d3092e413d55d0d')"
]
}
}
@@ -0,0 +1,460 @@
{
"formatVersion": 1,
"database": {
"version": 17,
"identityHash": "6fbe947ef0c6133ba5e621251a00fa1b",
"entities": [
{
"tableName": "messages",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `accountId` TEXT NOT NULL, `sender` TEXT NOT NULL, `senderEmail` TEXT NOT NULL, `subject` TEXT NOT NULL, `snippet` TEXT NOT NULL, `body` TEXT NOT NULL, `isHtml` INTEGER NOT NULL, `timestampMillis` INTEGER NOT NULL, `isRead` INTEGER NOT NULL, `isStarred` INTEGER NOT NULL, `folder` TEXT NOT NULL DEFAULT 'INBOX', `inInbox` INTEGER NOT NULL, `bodyFetched` INTEGER NOT NULL, `uid` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sender",
"columnName": "sender",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "senderEmail",
"columnName": "senderEmail",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "subject",
"columnName": "subject",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "snippet",
"columnName": "snippet",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "body",
"columnName": "body",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "isHtml",
"columnName": "isHtml",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "timestampMillis",
"columnName": "timestampMillis",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isRead",
"columnName": "isRead",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isStarred",
"columnName": "isStarred",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "folder",
"columnName": "folder",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "'INBOX'"
},
{
"fieldPath": "inInbox",
"columnName": "inInbox",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "bodyFetched",
"columnName": "bodyFetched",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "uid",
"columnName": "uid",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_messages_accountId",
"unique": false,
"columnNames": [
"accountId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_messages_accountId` ON `${TABLE_NAME}` (`accountId`)"
},
{
"name": "index_messages_timestampMillis",
"unique": false,
"columnNames": [
"timestampMillis"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_messages_timestampMillis` ON `${TABLE_NAME}` (`timestampMillis`)"
},
{
"name": "index_messages_accountId_folder_uid",
"unique": false,
"columnNames": [
"accountId",
"folder",
"uid"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_messages_accountId_folder_uid` ON `${TABLE_NAME}` (`accountId`, `folder`, `uid`)"
}
]
},
{
"tableName": "attachments",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`messageId` TEXT NOT NULL, `partIndex` INTEGER NOT NULL, `filename` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `sizeBytes` INTEGER NOT NULL, `contentId` TEXT, PRIMARY KEY(`messageId`, `partIndex`), FOREIGN KEY(`messageId`) REFERENCES `messages`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "messageId",
"columnName": "messageId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "partIndex",
"columnName": "partIndex",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "filename",
"columnName": "filename",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "mimeType",
"columnName": "mimeType",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sizeBytes",
"columnName": "sizeBytes",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "contentId",
"columnName": "contentId",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"messageId",
"partIndex"
]
},
"indices": [
{
"name": "index_attachments_messageId",
"unique": false,
"columnNames": [
"messageId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_attachments_messageId` ON `${TABLE_NAME}` (`messageId`)"
}
],
"foreignKeys": [
{
"table": "messages",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"messageId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "outbox",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `accountId` TEXT NOT NULL, `toAddresses` TEXT NOT NULL, `ccAddresses` TEXT NOT NULL, `bccAddresses` TEXT NOT NULL DEFAULT '', `subject` TEXT NOT NULL, `body` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `lastError` TEXT, `bodyHtml` TEXT, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "toAddresses",
"columnName": "toAddresses",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "ccAddresses",
"columnName": "ccAddresses",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "bccAddresses",
"columnName": "bccAddresses",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
},
{
"fieldPath": "subject",
"columnName": "subject",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "body",
"columnName": "body",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastError",
"columnName": "lastError",
"affinity": "TEXT"
},
{
"fieldPath": "bodyHtml",
"columnName": "bodyHtml",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "drafts",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `accountId` TEXT, `toAddresses` TEXT NOT NULL, `ccAddresses` TEXT NOT NULL, `bccAddresses` TEXT NOT NULL DEFAULT '', `subject` TEXT NOT NULL, `body` TEXT NOT NULL, `updatedAt` INTEGER NOT NULL, `attachments` TEXT NOT NULL, `bodyHtml` TEXT, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "TEXT"
},
{
"fieldPath": "toAddresses",
"columnName": "toAddresses",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "ccAddresses",
"columnName": "ccAddresses",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "bccAddresses",
"columnName": "bccAddresses",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
},
{
"fieldPath": "subject",
"columnName": "subject",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "body",
"columnName": "body",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updatedAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "attachments",
"columnName": "attachments",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "bodyHtml",
"columnName": "bodyHtml",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "folders",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `fullName` TEXT NOT NULL, `displayName` TEXT NOT NULL, `role` TEXT NOT NULL, `selectable` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL, `specialUse` INTEGER NOT NULL DEFAULT 0, `hierarchyDelimiter` TEXT, PRIMARY KEY(`accountId`, `fullName`))",
"fields": [
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "fullName",
"columnName": "fullName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "displayName",
"columnName": "displayName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "role",
"columnName": "role",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "selectable",
"columnName": "selectable",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "sortOrder",
"columnName": "sortOrder",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "specialUse",
"columnName": "specialUse",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "hierarchyDelimiter",
"columnName": "hierarchyDelimiter",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"accountId",
"fullName"
]
}
},
{
"tableName": "backfill_progress",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `folder` TEXT NOT NULL, `nextBeforeUid` INTEGER NOT NULL, `complete` INTEGER NOT NULL, PRIMARY KEY(`accountId`, `folder`))",
"fields": [
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "folder",
"columnName": "folder",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "nextBeforeUid",
"columnName": "nextBeforeUid",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "complete",
"columnName": "complete",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"accountId",
"folder"
]
}
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '6fbe947ef0c6133ba5e621251a00fa1b')"
]
}
}
@@ -0,0 +1,467 @@
{
"formatVersion": 1,
"database": {
"version": 18,
"identityHash": "f4b80b1d988222a691f7aeea4d45e37e",
"entities": [
{
"tableName": "messages",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `accountId` TEXT NOT NULL, `sender` TEXT NOT NULL, `senderEmail` TEXT NOT NULL, `subject` TEXT NOT NULL, `snippet` TEXT NOT NULL, `body` TEXT NOT NULL, `isHtml` INTEGER NOT NULL, `timestampMillis` INTEGER NOT NULL, `isRead` INTEGER NOT NULL, `isStarred` INTEGER NOT NULL, `folder` TEXT NOT NULL DEFAULT 'INBOX', `inInbox` INTEGER NOT NULL, `bodyFetched` INTEGER NOT NULL, `uid` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sender",
"columnName": "sender",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "senderEmail",
"columnName": "senderEmail",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "subject",
"columnName": "subject",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "snippet",
"columnName": "snippet",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "body",
"columnName": "body",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "isHtml",
"columnName": "isHtml",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "timestampMillis",
"columnName": "timestampMillis",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isRead",
"columnName": "isRead",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isStarred",
"columnName": "isStarred",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "folder",
"columnName": "folder",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "'INBOX'"
},
{
"fieldPath": "inInbox",
"columnName": "inInbox",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "bodyFetched",
"columnName": "bodyFetched",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "uid",
"columnName": "uid",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_messages_accountId",
"unique": false,
"columnNames": [
"accountId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_messages_accountId` ON `${TABLE_NAME}` (`accountId`)"
},
{
"name": "index_messages_timestampMillis",
"unique": false,
"columnNames": [
"timestampMillis"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_messages_timestampMillis` ON `${TABLE_NAME}` (`timestampMillis`)"
},
{
"name": "index_messages_accountId_folder_uid",
"unique": false,
"columnNames": [
"accountId",
"folder",
"uid"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_messages_accountId_folder_uid` ON `${TABLE_NAME}` (`accountId`, `folder`, `uid`)"
}
]
},
{
"tableName": "attachments",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`messageId` TEXT NOT NULL, `partIndex` INTEGER NOT NULL, `filename` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `sizeBytes` INTEGER NOT NULL, `contentId` TEXT, PRIMARY KEY(`messageId`, `partIndex`), FOREIGN KEY(`messageId`) REFERENCES `messages`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "messageId",
"columnName": "messageId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "partIndex",
"columnName": "partIndex",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "filename",
"columnName": "filename",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "mimeType",
"columnName": "mimeType",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sizeBytes",
"columnName": "sizeBytes",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "contentId",
"columnName": "contentId",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"messageId",
"partIndex"
]
},
"indices": [
{
"name": "index_attachments_messageId",
"unique": false,
"columnNames": [
"messageId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_attachments_messageId` ON `${TABLE_NAME}` (`messageId`)"
}
],
"foreignKeys": [
{
"table": "messages",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"messageId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "outbox",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `accountId` TEXT NOT NULL, `toAddresses` TEXT NOT NULL, `ccAddresses` TEXT NOT NULL, `bccAddresses` TEXT NOT NULL DEFAULT '', `subject` TEXT NOT NULL, `body` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `lastError` TEXT, `bodyHtml` TEXT, `attachments` TEXT NOT NULL DEFAULT '', PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "toAddresses",
"columnName": "toAddresses",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "ccAddresses",
"columnName": "ccAddresses",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "bccAddresses",
"columnName": "bccAddresses",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
},
{
"fieldPath": "subject",
"columnName": "subject",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "body",
"columnName": "body",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastError",
"columnName": "lastError",
"affinity": "TEXT"
},
{
"fieldPath": "bodyHtml",
"columnName": "bodyHtml",
"affinity": "TEXT"
},
{
"fieldPath": "attachments",
"columnName": "attachments",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "drafts",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `accountId` TEXT, `toAddresses` TEXT NOT NULL, `ccAddresses` TEXT NOT NULL, `bccAddresses` TEXT NOT NULL DEFAULT '', `subject` TEXT NOT NULL, `body` TEXT NOT NULL, `updatedAt` INTEGER NOT NULL, `attachments` TEXT NOT NULL, `bodyHtml` TEXT, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "TEXT"
},
{
"fieldPath": "toAddresses",
"columnName": "toAddresses",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "ccAddresses",
"columnName": "ccAddresses",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "bccAddresses",
"columnName": "bccAddresses",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
},
{
"fieldPath": "subject",
"columnName": "subject",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "body",
"columnName": "body",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updatedAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "attachments",
"columnName": "attachments",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "bodyHtml",
"columnName": "bodyHtml",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "folders",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `fullName` TEXT NOT NULL, `displayName` TEXT NOT NULL, `role` TEXT NOT NULL, `selectable` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL, `specialUse` INTEGER NOT NULL DEFAULT 0, `hierarchyDelimiter` TEXT, PRIMARY KEY(`accountId`, `fullName`))",
"fields": [
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "fullName",
"columnName": "fullName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "displayName",
"columnName": "displayName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "role",
"columnName": "role",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "selectable",
"columnName": "selectable",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "sortOrder",
"columnName": "sortOrder",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "specialUse",
"columnName": "specialUse",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "hierarchyDelimiter",
"columnName": "hierarchyDelimiter",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"accountId",
"fullName"
]
}
},
{
"tableName": "backfill_progress",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `folder` TEXT NOT NULL, `nextBeforeUid` INTEGER NOT NULL, `complete` INTEGER NOT NULL, PRIMARY KEY(`accountId`, `folder`))",
"fields": [
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "folder",
"columnName": "folder",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "nextBeforeUid",
"columnName": "nextBeforeUid",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "complete",
"columnName": "complete",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"accountId",
"folder"
]
}
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'f4b80b1d988222a691f7aeea4d45e37e')"
]
}
}
@@ -0,0 +1,460 @@
{
"formatVersion": 1,
"database": {
"version": 8,
"identityHash": "f00f0bf1851fb32d76dacee8558ae87a",
"entities": [
{
"tableName": "accounts",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `email` TEXT NOT NULL, `displayName` TEXT NOT NULL, `authType` TEXT NOT NULL, `imap_host` TEXT NOT NULL, `imap_port` INTEGER NOT NULL, `imap_security` TEXT NOT NULL, `smtp_host` TEXT NOT NULL, `smtp_port` INTEGER NOT NULL, `smtp_security` TEXT NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "email",
"columnName": "email",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "displayName",
"columnName": "displayName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "authType",
"columnName": "authType",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "imap.host",
"columnName": "imap_host",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "imap.port",
"columnName": "imap_port",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "imap.security",
"columnName": "imap_security",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "smtp.host",
"columnName": "smtp_host",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "smtp.port",
"columnName": "smtp_port",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "smtp.security",
"columnName": "smtp_security",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "messages",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `accountId` TEXT NOT NULL, `sender` TEXT NOT NULL, `senderEmail` TEXT NOT NULL, `subject` TEXT NOT NULL, `snippet` TEXT NOT NULL, `body` TEXT NOT NULL, `isHtml` INTEGER NOT NULL, `timestampMillis` INTEGER NOT NULL, `isRead` INTEGER NOT NULL, `isStarred` INTEGER NOT NULL, `folder` TEXT NOT NULL DEFAULT 'INBOX', `inInbox` INTEGER NOT NULL, `bodyFetched` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sender",
"columnName": "sender",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "senderEmail",
"columnName": "senderEmail",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "subject",
"columnName": "subject",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "snippet",
"columnName": "snippet",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "body",
"columnName": "body",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "isHtml",
"columnName": "isHtml",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "timestampMillis",
"columnName": "timestampMillis",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isRead",
"columnName": "isRead",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isStarred",
"columnName": "isStarred",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "folder",
"columnName": "folder",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "'INBOX'"
},
{
"fieldPath": "inInbox",
"columnName": "inInbox",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "bodyFetched",
"columnName": "bodyFetched",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_messages_accountId",
"unique": false,
"columnNames": [
"accountId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_messages_accountId` ON `${TABLE_NAME}` (`accountId`)"
},
{
"name": "index_messages_timestampMillis",
"unique": false,
"columnNames": [
"timestampMillis"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_messages_timestampMillis` ON `${TABLE_NAME}` (`timestampMillis`)"
}
]
},
{
"tableName": "credentials",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `encryptedSecret` TEXT NOT NULL, PRIMARY KEY(`accountId`))",
"fields": [
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "encryptedSecret",
"columnName": "encryptedSecret",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"accountId"
]
}
},
{
"tableName": "attachments",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`messageId` TEXT NOT NULL, `partIndex` INTEGER NOT NULL, `filename` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `sizeBytes` INTEGER NOT NULL, PRIMARY KEY(`messageId`, `partIndex`), FOREIGN KEY(`messageId`) REFERENCES `messages`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "messageId",
"columnName": "messageId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "partIndex",
"columnName": "partIndex",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "filename",
"columnName": "filename",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "mimeType",
"columnName": "mimeType",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sizeBytes",
"columnName": "sizeBytes",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"messageId",
"partIndex"
]
},
"indices": [
{
"name": "index_attachments_messageId",
"unique": false,
"columnNames": [
"messageId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_attachments_messageId` ON `${TABLE_NAME}` (`messageId`)"
}
],
"foreignKeys": [
{
"table": "messages",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"messageId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "outbox",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `accountId` TEXT NOT NULL, `toAddresses` TEXT NOT NULL, `ccAddresses` TEXT NOT NULL, `subject` TEXT NOT NULL, `body` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `lastError` TEXT, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "toAddresses",
"columnName": "toAddresses",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "ccAddresses",
"columnName": "ccAddresses",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "subject",
"columnName": "subject",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "body",
"columnName": "body",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastError",
"columnName": "lastError",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "drafts",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `accountId` TEXT, `toAddresses` TEXT NOT NULL, `ccAddresses` TEXT NOT NULL, `subject` TEXT NOT NULL, `body` TEXT NOT NULL, `updatedAt` INTEGER NOT NULL, `attachments` TEXT NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "TEXT"
},
{
"fieldPath": "toAddresses",
"columnName": "toAddresses",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "ccAddresses",
"columnName": "ccAddresses",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "subject",
"columnName": "subject",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "body",
"columnName": "body",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updatedAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "attachments",
"columnName": "attachments",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "folders",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `fullName` TEXT NOT NULL, `displayName` TEXT NOT NULL, `role` TEXT NOT NULL, `selectable` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL, PRIMARY KEY(`accountId`, `fullName`))",
"fields": [
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "fullName",
"columnName": "fullName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "displayName",
"columnName": "displayName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "role",
"columnName": "role",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "selectable",
"columnName": "selectable",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "sortOrder",
"columnName": "sortOrder",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"accountId",
"fullName"
]
}
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'f00f0bf1851fb32d76dacee8558ae87a')"
]
}
}
@@ -0,0 +1,509 @@
{
"formatVersion": 1,
"database": {
"version": 9,
"identityHash": "e753f578e604e04fa7a697c18e80cb0b",
"entities": [
{
"tableName": "accounts",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `email` TEXT NOT NULL, `displayName` TEXT NOT NULL, `authType` TEXT NOT NULL, `imap_host` TEXT NOT NULL, `imap_port` INTEGER NOT NULL, `imap_security` TEXT NOT NULL, `smtp_host` TEXT NOT NULL, `smtp_port` INTEGER NOT NULL, `smtp_security` TEXT NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "email",
"columnName": "email",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "displayName",
"columnName": "displayName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "authType",
"columnName": "authType",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "imap.host",
"columnName": "imap_host",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "imap.port",
"columnName": "imap_port",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "imap.security",
"columnName": "imap_security",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "smtp.host",
"columnName": "smtp_host",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "smtp.port",
"columnName": "smtp_port",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "smtp.security",
"columnName": "smtp_security",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "account_settings",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `signature` TEXT NOT NULL, `signatureEnabled` INTEGER NOT NULL, `notificationsEnabled` INTEGER NOT NULL, PRIMARY KEY(`accountId`), FOREIGN KEY(`accountId`) REFERENCES `accounts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "signature",
"columnName": "signature",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "signatureEnabled",
"columnName": "signatureEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationsEnabled",
"columnName": "notificationsEnabled",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"accountId"
]
},
"foreignKeys": [
{
"table": "accounts",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"accountId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "messages",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `accountId` TEXT NOT NULL, `sender` TEXT NOT NULL, `senderEmail` TEXT NOT NULL, `subject` TEXT NOT NULL, `snippet` TEXT NOT NULL, `body` TEXT NOT NULL, `isHtml` INTEGER NOT NULL, `timestampMillis` INTEGER NOT NULL, `isRead` INTEGER NOT NULL, `isStarred` INTEGER NOT NULL, `folder` TEXT NOT NULL DEFAULT 'INBOX', `inInbox` INTEGER NOT NULL, `bodyFetched` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sender",
"columnName": "sender",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "senderEmail",
"columnName": "senderEmail",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "subject",
"columnName": "subject",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "snippet",
"columnName": "snippet",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "body",
"columnName": "body",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "isHtml",
"columnName": "isHtml",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "timestampMillis",
"columnName": "timestampMillis",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isRead",
"columnName": "isRead",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isStarred",
"columnName": "isStarred",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "folder",
"columnName": "folder",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "'INBOX'"
},
{
"fieldPath": "inInbox",
"columnName": "inInbox",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "bodyFetched",
"columnName": "bodyFetched",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_messages_accountId",
"unique": false,
"columnNames": [
"accountId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_messages_accountId` ON `${TABLE_NAME}` (`accountId`)"
},
{
"name": "index_messages_timestampMillis",
"unique": false,
"columnNames": [
"timestampMillis"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_messages_timestampMillis` ON `${TABLE_NAME}` (`timestampMillis`)"
}
]
},
{
"tableName": "credentials",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `encryptedSecret` TEXT NOT NULL, PRIMARY KEY(`accountId`))",
"fields": [
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "encryptedSecret",
"columnName": "encryptedSecret",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"accountId"
]
}
},
{
"tableName": "attachments",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`messageId` TEXT NOT NULL, `partIndex` INTEGER NOT NULL, `filename` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `sizeBytes` INTEGER NOT NULL, PRIMARY KEY(`messageId`, `partIndex`), FOREIGN KEY(`messageId`) REFERENCES `messages`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "messageId",
"columnName": "messageId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "partIndex",
"columnName": "partIndex",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "filename",
"columnName": "filename",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "mimeType",
"columnName": "mimeType",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sizeBytes",
"columnName": "sizeBytes",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"messageId",
"partIndex"
]
},
"indices": [
{
"name": "index_attachments_messageId",
"unique": false,
"columnNames": [
"messageId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_attachments_messageId` ON `${TABLE_NAME}` (`messageId`)"
}
],
"foreignKeys": [
{
"table": "messages",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"messageId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "outbox",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `accountId` TEXT NOT NULL, `toAddresses` TEXT NOT NULL, `ccAddresses` TEXT NOT NULL, `subject` TEXT NOT NULL, `body` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `lastError` TEXT, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "toAddresses",
"columnName": "toAddresses",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "ccAddresses",
"columnName": "ccAddresses",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "subject",
"columnName": "subject",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "body",
"columnName": "body",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastError",
"columnName": "lastError",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "drafts",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `accountId` TEXT, `toAddresses` TEXT NOT NULL, `ccAddresses` TEXT NOT NULL, `subject` TEXT NOT NULL, `body` TEXT NOT NULL, `updatedAt` INTEGER NOT NULL, `attachments` TEXT NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "TEXT"
},
{
"fieldPath": "toAddresses",
"columnName": "toAddresses",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "ccAddresses",
"columnName": "ccAddresses",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "subject",
"columnName": "subject",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "body",
"columnName": "body",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updatedAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "attachments",
"columnName": "attachments",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "folders",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `fullName` TEXT NOT NULL, `displayName` TEXT NOT NULL, `role` TEXT NOT NULL, `selectable` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL, PRIMARY KEY(`accountId`, `fullName`))",
"fields": [
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "fullName",
"columnName": "fullName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "displayName",
"columnName": "displayName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "role",
"columnName": "role",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "selectable",
"columnName": "selectable",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "sortOrder",
"columnName": "sortOrder",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"accountId",
"fullName"
]
}
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'e753f578e604e04fa7a697c18e80cb0b')"
]
}
}
@@ -0,0 +1,46 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail
import android.content.Intent
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
/**
* Locks in the #157 regression fix: [IntentHandledMarker] must tell a redelivered (already-parsed)
* intent apart from a genuinely new one using only the [Intent] instance itself, never
* `savedInstanceState` — which Android also sets to non-null after a process-death relaunch, where the
* delivered intent is a brand new, not-yet-handled notification tap or mailto/share, not a replay.
*/
@RunWith(AndroidJUnit4::class)
class IntentHandledMarkerTest {
@Test
fun first_look_marks_the_intent_and_reports_it_as_unhandled() {
val intent = Intent(Intent.ACTION_MAIN)
assertTrue(IntentHandledMarker.markIfUnhandled(intent))
}
@Test
fun redelivering_the_same_instance_is_recognized_as_already_handled() {
// Simulates a config-change recreation: Android redelivers the very same Intent object to the
// new Activity instance's onCreate, so a second look must be recognized as a replay.
val intent = Intent(Intent.ACTION_MAIN)
IntentHandledMarker.markIfUnhandled(intent)
assertFalse(IntentHandledMarker.markIfUnhandled(intent))
}
@Test
fun a_freshly_constructed_equivalent_intent_is_still_unhandled() {
// Simulates a process-death relaunch (e.g. tapping a notification after the app was killed in
// the background): a brand new Intent instance arrives — content may equal one already marked
// in a prior (now-dead) process, but it was never marked in THIS one, so it must be parsed.
val first = Intent(Intent.ACTION_VIEW)
IntentHandledMarker.markIfUnhandled(first)
val second = Intent(Intent.ACTION_VIEW)
assertTrue(IntentHandledMarker.markIfUnhandled(second))
}
}
@@ -0,0 +1,265 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.data.local
import android.content.Context
import androidx.room.Room
import androidx.room.testing.MigrationTestHelper
import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import org.json.JSONObject
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.libremail.data.local.entity.CredentialEntity
/**
* The one-time move performed by [AccountDataMigrator] (issue #111): copying accounts / credentials /
* per-account settings / signatures out of the cache database into the plaintext [AccountDatabase].
*
* Exercises the [AccountDataMigrator.copyAccountTables] core directly (the full [AccountDataMigrator]
* also resolves the passphrase and flips the done-flag, which need the real DataStore/Keystore). A v14
* cache is built with [MigrationTestHelper] from the exported schema, so the copy runs against exactly
* the on-disk shape an upgrading user has.
*/
@RunWith(AndroidJUnit4::class)
class AccountDataMigratorTest {
@get:Rule
val helper = MigrationTestHelper(
InstrumentationRegistry.getInstrumentation(),
LibreMailDatabase::class.java,
emptyList(),
FrameworkSQLiteOpenHelperFactory(),
)
private val context = ApplicationProvider.getApplicationContext<Context>()
private val cacheName = "acct-migrator-cache-test.db"
private val accountsName = "acct-migrator-accounts-test.db"
private val cacheFile get() = context.getDatabasePath(cacheName)
private val accountsFile get() = context.getDatabasePath(accountsName)
// 64 hex chars == a 32-byte SQLCipher passphrase.
private val passphrase = "0123456789abcdef".repeat(4)
@Before
@After
fun clean() {
listOf(cacheName, accountsName).forEach { name ->
context.deleteDatabase(name)
context.getDatabasePath(name).parentFile
?.listFiles { f -> f.name.startsWith(name) }
?.forEach { it.delete() }
}
}
/** Builds a v14 cache holding one fully-populated account plus a mail row. */
private fun seedVersion14Cache() {
helper.createDatabase(cacheName, 14).apply {
execSQL(
"INSERT INTO accounts (id, email, displayName, authType, imap_host, imap_port, imap_security, " +
"smtp_host, smtp_port, smtp_security) VALUES ('acct', 'ada@example.org', 'Ada', " +
"'PASSWORD_IMAP', 'imap.example.org', 993, 'SSL_TLS', 'smtp.example.org', 465, 'SSL_TLS')",
)
execSQL("INSERT INTO credentials (accountId, encryptedSecret) VALUES ('acct', 'sealed-secret')")
execSQL(
"INSERT INTO account_settings (accountId, signature, signatureEnabled, notificationsEnabled, " +
"retentionCount, retentionMonths) VALUES ('acct', 'Cheers', 1, 0, NULL, 6)",
)
execSQL(
"INSERT INTO signatures (id, accountId, name, contentHtml, isDefault) " +
"VALUES ('sig-1', 'acct', 'Work', '<p>Regards</p>', 1)",
)
execSQL(
"INSERT INTO messages (id, accountId, sender, senderEmail, subject, snippet, body, isHtml, " +
"timestampMillis, isRead, isStarred, folder, inInbox, bodyFetched, uid) VALUES " +
"('acct:INBOX:1', 'acct', 'Ada', 'a@x', 'Hi', '', '', 0, 1, 0, 0, 'INBOX', 1, 0, 1)",
)
close()
}
}
private fun openAccountsDb(): AccountDatabase =
Room.databaseBuilder(context, AccountDatabase::class.java, accountsName).build()
@Test
fun movesEveryAccountTableOutOfAPlaintextCache() = runBlocking<Unit> {
seedVersion14Cache()
AccountDataMigrator.copyAccountTables(cacheFile, cachePassphrase = "", accountsFile = accountsFile)
// First open lets Room stamp its identity onto the migrator-created file; reopen so a real
// session's reads run against a fully Room-owned database.
openAccountsDb().apply {
assertEquals("Ada", accountDao().getById("acct")?.displayName)
close()
}
openAccountsDb().apply {
val account = accountDao().getById("acct")
assertEquals("ada@example.org", account?.email)
assertEquals(993, account?.imap?.port)
assertEquals("smtp.example.org", account?.smtp?.host)
assertEquals("sealed-secret", credentialDao().getById("acct")?.encryptedSecret)
val settings = accountSettingsDao().get("acct")
// Seeded signatureEnabled = 1, notificationsEnabled = 0: both booleans must round-trip.
assertEquals(true, settings?.signatureEnabled)
assertEquals(false, settings?.notificationsEnabled)
assertEquals(6, settings?.retentionMonths)
assertNull(settings?.retentionCount)
val signatures = signatureDao().observeForAccount("acct").first()
assertEquals(listOf("Work"), signatures.map { it.name })
assertTrue("the default flag must round-trip", signatures.single().isDefault)
close()
}
}
@Test
fun movesAccountsOutOfAnEncryptedCache() = runBlocking<Unit> {
seedVersion14Cache()
// Turn the cache into the SQLCipher form an app-lock + encrypted-cache user has on disk.
DatabaseEncryption.ensureEncrypted(cacheFile, passphrase)
assertTrue("precondition: the source cache is encrypted", DatabaseEncryption.isEncrypted(cacheFile))
AccountDataMigrator.copyAccountTables(cacheFile, cachePassphrase = passphrase, accountsFile = accountsFile)
openAccountsDb().apply {
assertNotNull(accountDao().getById("acct"))
close()
}
openAccountsDb().apply {
assertEquals("ada@example.org", accountDao().getById("acct")?.email)
assertEquals("sealed-secret", credentialDao().getById("acct")?.encryptedSecret)
close()
}
}
@Test
fun reRunningTheCopyIsIdempotentAndKeepsLaterEdits() = runBlocking<Unit> {
seedVersion14Cache()
AccountDataMigrator.copyAccountTables(cacheFile, cachePassphrase = "", accountsFile = accountsFile)
// Simulate the user editing an account AFTER the migration.
openAccountsDb().apply {
val edited = accountDao().getById("acct")!!.copy(displayName = "Ada Lovelace")
accountDao().upsert(edited)
close()
}
// A re-run (e.g. after a mid-startup crash before the done-flag was set) must not clobber it.
AccountDataMigrator.copyAccountTables(cacheFile, cachePassphrase = "", accountsFile = accountsFile)
openAccountsDb().apply {
assertEquals(1, accountDao().getAll().size)
assertEquals(
"INSERT OR IGNORE must not overwrite the post-migration edit",
"Ada Lovelace",
accountDao().getById("acct")?.displayName,
)
close()
}
}
@Test
fun accountsAndCredentialsSurviveACacheWipe() = runBlocking<Unit> {
seedVersion14Cache()
AccountDataMigrator.copyAccountTables(cacheFile, cachePassphrase = "", accountsFile = accountsFile)
// The "clear + re-sync" recovery wipes only the cache file; AccountDatabase is a separate file.
context.deleteDatabase(cacheName)
assertTrue("precondition: the cache file is gone", !cacheFile.exists())
openAccountsDb().apply {
assertNotNull("the account must outlive a cache wipe (issue #111)", accountDao().getById("acct"))
assertEquals("sealed-secret", credentialDao().getById("acct")?.encryptedSecret)
// And it is still usable: a fresh credential can be written with no cache present.
credentialDao().upsert(CredentialEntity("acct", "rotated"))
assertEquals("rotated", credentialDao().getById("acct")?.encryptedSecret)
close()
}
}
@Test
fun copiesFromACacheOlderThanTheCurrentSchema() = runBlocking<Unit> {
// A cache last written at v12 — before account_settings gained retentionCount/retentionMonths
// (v13). The copy must not choke on the columns the destination has but the source lacks
// (a device upgrade from an old install crashed the migrator here).
helper.createDatabase(cacheName, 12).apply {
execSQL(
"INSERT INTO accounts (id, email, displayName, authType, imap_host, imap_port, imap_security, " +
"smtp_host, smtp_port, smtp_security) VALUES ('acct', 'ada@example.org', 'Ada', " +
"'PASSWORD_IMAP', 'imap.example.org', 993, 'SSL_TLS', 'smtp.example.org', 465, 'SSL_TLS')",
)
execSQL("INSERT INTO credentials (accountId, encryptedSecret) VALUES ('acct', 'sealed-secret')")
execSQL(
"INSERT INTO account_settings (accountId, signature, signatureEnabled, notificationsEnabled) " +
"VALUES ('acct', 'Sig', 0, 1)",
)
close()
}
AccountDataMigrator.copyAccountTables(cacheFile, cachePassphrase = "", accountsFile = accountsFile)
openAccountsDb().apply {
assertEquals("ada@example.org", accountDao().getById("acct")?.email)
assertEquals("sealed-secret", credentialDao().getById("acct")?.encryptedSecret)
val settings = accountSettingsDao().get("acct")
assertEquals(false, settings?.signatureEnabled)
assertEquals(true, settings?.notificationsEnabled)
// Columns the v12 source lacked come across as the destination's defaults (null).
assertNull("retentionCount absent from a v12 cache must default to null", settings?.retentionCount)
assertNull(settings?.retentionMonths)
close()
}
}
@Test
fun migratorDdlMatchesExportedAccountDatabaseSchema() {
val schema = JSONObject(
InstrumentationRegistry.getInstrumentation().context.assets
.open("org.libremail.data.local.AccountDatabase/1.json")
.bufferedReader().use { it.readText() },
).getJSONObject("database")
val entities = schema.getJSONArray("entities")
var checkedIndex = false
for (i in 0 until entities.length()) {
val entity = entities.getJSONObject(i)
val table = entity.getString("tableName")
val expectedCreate = entity.getString("createSql").replace("\${TABLE_NAME}", table)
assertEquals(
"AccountDataMigrator DDL for `$table` must match the exported AccountDatabase schema",
expectedCreate,
AccountDataMigrator.CREATE_TABLE_SQL[table],
)
if (entity.has("indices")) {
val indices = entity.getJSONArray("indices")
for (j in 0 until indices.length()) {
val index = indices.getJSONObject(j)
if (index.getString("name") == "index_signatures_accountId") {
assertEquals(
"AccountDataMigrator signatures index must match the exported schema",
index.getString("createSql").replace("\${TABLE_NAME}", table),
AccountDataMigrator.SIGNATURES_INDEX_SQL,
)
checkedIndex = true
}
}
}
}
assertEquals(
"every migrator table DDL must correspond to an exported entity",
AccountDataMigrator.CREATE_TABLE_SQL.keys,
(0 until entities.length()).map { entities.getJSONObject(it).getString("tableName") }.toSet(),
)
assertTrue("the signatures index must be present in the exported schema", checkedIndex)
}
}
@@ -0,0 +1,85 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.data.local
import android.content.Context
import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.libremail.data.local.entity.AccountEntity
import org.libremail.data.local.entity.AccountSettingsEntity
import org.libremail.data.local.entity.CredentialEntity
import org.libremail.data.local.entity.ServerConfigEmbedded
import org.libremail.data.local.entity.SignatureEntity
/**
* Behavior of the non-auth [AccountDatabase] that holds accounts, credentials, per-account settings
* and signatures after they were moved out of the cache database (issue #111). Confirms the tables'
* foreign keys still cascade from the account, now that they live together in this database.
*/
@RunWith(AndroidJUnit4::class)
class AccountDatabaseTest {
private lateinit var db: AccountDatabase
@Before
fun setUp() {
val context = ApplicationProvider.getApplicationContext<Context>()
db = Room.inMemoryDatabaseBuilder(context, AccountDatabase::class.java).build()
}
@After
fun tearDown() = db.close()
private fun account(id: String = "acct") = AccountEntity(
id = id,
email = "a@example.org",
displayName = "A",
authType = "PASSWORD_IMAP",
imap = ServerConfigEmbedded("imap.example.org", 993, "SSL_TLS"),
smtp = ServerConfigEmbedded("smtp.example.org", 465, "SSL_TLS"),
)
@Test
fun credentialRoundTripsAndIsIndependentOfTheAccountRow() = runBlocking<Unit> {
db.accountDao().upsert(account())
db.credentialDao().upsert(CredentialEntity("acct", "sealed-secret"))
assertEquals("sealed-secret", db.credentialDao().getById("acct")?.encryptedSecret)
}
@Test
fun accountSettingsRoundTripAndCascadeWithTheirAccount() = runBlocking<Unit> {
db.accountDao().upsert(account())
db.accountSettingsDao().upsert(
AccountSettingsEntity("acct", signature = "Hi", signatureEnabled = false, notificationsEnabled = false),
)
assertEquals("Hi", db.accountSettingsDao().get("acct")?.signature)
db.accountDao().deleteById("acct")
assertNull("account_settings must cascade-delete with its account", db.accountSettingsDao().get("acct"))
}
@Test
fun signaturesCascadeWithTheirAccount() = runBlocking<Unit> {
db.accountDao().upsert(account())
db.signatureDao().upsert(SignatureEntity("sig-1", "acct", "Work", "<p>Regards</p>", isDefault = true))
assertEquals(1, db.signatureDao().observeForAccount("acct").first().size)
db.accountDao().deleteById("acct")
assertTrue(
"signatures must cascade-delete with their account",
db.signatureDao().observeForAccount("acct").first().isEmpty(),
)
}
}
@@ -5,7 +5,6 @@ import android.content.Context
import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import java.io.File
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import net.zetetic.database.sqlcipher.SupportOpenHelperFactory
@@ -17,6 +16,7 @@ import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.libremail.data.local.entity.MessageEntity
import java.io.File
/**
* Round-trips the cache database through [DatabaseEncryption] (plaintext → encrypted → plaintext),
@@ -53,7 +53,7 @@ class DatabaseEncryptionTest {
DatabaseEncryption.ensureEncrypted(dbFile, passphrase)
assertTrue("file must not read as plaintext once encrypted", DatabaseEncryption.isEncrypted(dbFile))
openEncrypted().apply {
assertEquals(listOf("acct:1"), messageDao().observeAll().first().map { it.id })
assertEquals(listOf("acct:1"), messageDao().observeSummaries().first().map { it.id })
close()
}
@@ -61,7 +61,7 @@ class DatabaseEncryptionTest {
DatabaseEncryption.ensurePlaintext(dbFile, passphrase)
assertFalse("file must be plaintext again after decrypt", DatabaseEncryption.isEncrypted(dbFile))
openPlaintext().apply {
assertEquals(listOf("acct:1"), messageDao().observeAll().first().map { it.id })
assertEquals(listOf("acct:1"), messageDao().observeSummaries().first().map { it.id })
close()
}
}
@@ -14,12 +14,16 @@ import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.libremail.data.local.entity.AttachmentEntity
import org.libremail.data.local.entity.FolderEntity
import org.libremail.data.local.entity.MessageEntity
/**
* Schema-behavior tests on the real (v7) Room database. (Migrations from versions before
* exportSchema was enabled can't be replayed with MigrationTestHelper, since their schema JSONs
* were never exported; exportSchema is now on so future migrations can be tested.)
* Schema-behavior tests on a fresh in-memory database at the current version. The migration DDL
* itself is exercised by [MigrationTest], which replays the schema chain exported to app/schemas.
* (Migrations from before v7 predate schema export, so they can't be replayed there.)
*
* Account/credential/settings/signature behavior moved to [AccountDatabaseTest] with those tables
* (issue #111).
*/
@RunWith(AndroidJUnit4::class)
class LibreMailDatabaseTest {
@@ -48,6 +52,34 @@ class LibreMailDatabaseTest {
isStarred = false,
)
@Test
fun observeUnreadCountsAggregatesUnreadSyncedRowsPerAccountAndFolder() = runBlocking {
val messageDao = db.messageDao()
messageDao.insertNew(
listOf(
// acct / INBOX: two unread + one read -> counts 2.
message("acct:INBOX:1").copy(folder = "INBOX", isRead = false),
message("acct:INBOX:2").copy(folder = "INBOX", isRead = false),
message("acct:INBOX:3").copy(folder = "INBOX", isRead = true),
// acct / Archive: one unread -> counts 1.
message("acct:Archive:1").copy(folder = "Archive", isRead = false),
// An unread server-search hit (inInbox = false) must never inflate a badge.
message("acct:INBOX:search").copy(folder = "INBOX", isRead = false, inInbox = false),
// A second account's unread inbox row is counted under its own accountId.
message("acct2:INBOX:1").copy(accountId = "acct2", folder = "INBOX", isRead = false),
),
)
val counts = messageDao.observeUnreadCounts().first()
.associate { (it.accountId to it.folder) to it.unreadCount }
assertEquals(2, counts[("acct" to "INBOX")])
assertEquals(1, counts[("acct" to "Archive")])
assertEquals(1, counts[("acct2" to "INBOX")])
// Fully-read folders and search-only rows produce no group at all.
assertEquals(3, counts.size)
}
@Test
fun deletingMessageCascadesToItsAttachments() = runBlocking {
val messageDao = db.messageDao()
@@ -64,16 +96,107 @@ class LibreMailDatabaseTest {
)
}
@Test
fun observeForMessageHidesInlineImagesWhileGetForMessageKeepsThem() = runBlocking {
val messageDao = db.messageDao()
val attachmentDao = db.attachmentDao()
messageDao.insertNew(listOf(message("acct:1")))
attachmentDao.insert(
listOf(
AttachmentEntity("acct:1", 0, "logo.png", "image/png", 4, contentId = "logo1"),
AttachmentEntity("acct:1", 1, "invoice.pdf", "application/pdf", 10, contentId = null),
),
)
// The displayed list excludes inline cid: images (issue #133) ...
val displayed = attachmentDao.observeForMessage("acct:1").first()
assertEquals(listOf("invoice.pdf"), displayed.map { it.filename })
// ... while the full read keeps them so their bytes can back a cid: request.
assertEquals(2, attachmentDao.getForMessage("acct:1").size)
}
@Test
fun searchRowsAreNotInboxAndAreCleared() = runBlocking {
val messageDao = db.messageDao()
messageDao.insertNew(listOf(message("acct:1").copy(inInbox = true)))
messageDao.insertNew(listOf(message("acct:2").copy(inInbox = false)))
assertEquals(listOf("acct:1"), messageDao.getInboxIdsForAccount("acct"))
assertEquals(listOf("acct:1"), messageDao.getSyncedIds("acct", "INBOX"))
messageDao.deleteSearchRows()
val remaining = messageDao.observeAll().first().map { it.id }
val remaining = messageDao.observeSummaries().first().map { it.id }
assertEquals(listOf("acct:1"), remaining)
}
@Test
fun observeSummariesReadsRowsWhoseBodiesExceedTheCursorWindow() = runBlocking {
val messageDao = db.messageDao()
// Each body is larger than SQLite's shared (~2 MB) CursorWindow. The old list query did
// SELECT * and dragged these bodies through the window, overflowing it with
// "Couldn't read row … from CursorWindow" (issue #51). observeSummaries omits body, so the
// rows stay tiny and read fine.
val hugeBody = "x".repeat(3 * 1024 * 1024)
messageDao.insertNew(
listOf(
message("acct:1", body = hugeBody),
message("acct:2", body = hugeBody),
),
)
val ids = messageDao.observeSummaries().first().map { it.id }.toSet()
assertEquals(setOf("acct:1", "acct:2"), ids)
}
@Test
fun foldersAreStoredOrderedAndReplaceablePerAccount() = runBlocking {
val folderDao = db.folderDao()
folderDao.replaceForAccount(
"acct",
listOf(
FolderEntity(
accountId = "acct",
fullName = "[Gmail]/Sent Mail",
displayName = "Sent Mail",
role = "SENT",
selectable = true,
sortOrder = 1,
specialUse = true,
),
FolderEntity("acct", "INBOX", "INBOX", "INBOX", selectable = true, sortOrder = 0),
),
)
// observeForAccount returns folders ordered by sortOrder, with specialUse round-tripped.
val stored = folderDao.observeForAccount("acct").first()
assertEquals(listOf("INBOX", "[Gmail]/Sent Mail"), stored.map { it.fullName })
assertEquals(listOf(false, true), stored.map { it.specialUse })
// replaceForAccount swaps the whole set (delete + insert).
folderDao.replaceForAccount("acct", listOf(FolderEntity("acct", "Archive", "Archive", "ARCHIVE", true, 0)))
assertEquals(listOf("Archive"), folderDao.observeForAccount("acct").first().map { it.fullName })
}
@Test
fun syncReconcileIsScopedToASingleFolder() = runBlocking {
val messageDao = db.messageDao()
messageDao.insertNew(
listOf(
message("acct:INBOX:1").copy(folder = "INBOX"),
message("acct:INBOX:2").copy(folder = "INBOX"),
message("acct:Archive:1").copy(folder = "Archive"),
),
)
// getSyncedIds is scoped to one folder.
assertEquals(setOf("acct:INBOX:1", "acct:INBOX:2"), messageDao.getSyncedIds("acct", "INBOX").toSet())
assertEquals(listOf("acct:Archive:1"), messageDao.getSyncedIds("acct", "Archive"))
// Reconciling the inbox must not touch other folders' rows (windowed reconcile; whole-inbox
// window since these rows have uid 0).
messageDao.deleteSyncedInWindowNotIn("acct", "INBOX", minWindowUid = 0, keepIds = listOf("acct:INBOX:1"))
assertEquals(
setOf("acct:INBOX:1", "acct:Archive:1"),
messageDao.observeSummaries().first().map { it.id }.toSet(),
)
}
}
@@ -0,0 +1,199 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.data.local
import android.content.Context
import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.libremail.data.local.dao.MessageDao
import org.libremail.data.local.entity.MessageEntity
/**
* Real-SQLite behavior of the retention / backfill boundary queries on [MessageDao] (issues #12/#13).
*
* These queries *define* the device-only retention floor — the pruner deletes below it
* ([MessageDao.syncedIdsBeyondCountInFolder] / [MessageDao.syncedIdsOlderThan]) and the backfiller
* pages and stops around it ([MessageDao.lowestSyncedUid] / [MessageDao.countSynced]) — so the whole
* "backfill and prune never fight over the same rows" guarantee rests on their SQL. The
* [org.libremail.data.sync.MailPruner] / [org.libremail.data.sync.MailBackfiller] unit tests mock
* the DAO, so the `ORDER BY … DESC LIMIT` newest-N selection, the strict age cutoff, and the
* windowed reconcile that spares backfilled history are exercised here against a real database
* instead.
*/
@RunWith(AndroidJUnit4::class)
class MessageDaoRetentionTest {
private lateinit var db: LibreMailDatabase
private lateinit var dao: MessageDao
@Before
fun setUp() {
val context = ApplicationProvider.getApplicationContext<Context>()
db = Room.inMemoryDatabaseBuilder(context, LibreMailDatabase::class.java).build()
dao = db.messageDao()
}
@After
fun tearDown() = db.close()
private fun message(
id: String,
accountId: String = "acct",
folder: String = "INBOX",
uid: Long = 0L,
timestampMillis: Long = 1_000L,
inInbox: Boolean = true,
) = MessageEntity(
id = id,
accountId = accountId,
sender = "Ada",
senderEmail = "ada@example.org",
subject = "Hi",
snippet = "",
body = "",
timestampMillis = timestampMillis,
isRead = false,
isStarred = false,
folder = folder,
inInbox = inInbox,
uid = uid,
)
/**
* The count-based prune boundary keeps the newest [keep] by ARRIVAL (server UID) and returns the
* REST for deletion. Keeping by UID — not by the Date header — matches the newest-by-UID recent
* window foreground sync re-fetches, so a high-UID/old-Date message isn't re-downloaded every sync
* and re-pruned every cycle. This pins the ordering column (a Date-ordered keep would evict the
* high-UID/old-Date row) and its direction (a flipped DESC would keep the OLDEST arrivals).
*/
@Test
fun syncedIdsBeyondCountInFolderKeepsNewestByArrivalUid() = runBlocking {
dao.insertNew(
listOf(
message("recent-old-date", uid = 100, timestampMillis = 50), // newest arrival, oldest Date
message("A", uid = 30, timestampMillis = 300),
message("B", uid = 25, timestampMillis = 200),
message("D", uid = 10, timestampMillis = 100),
// Scoping decoys: a search-only row and another folder must never enter the ranking.
message("SR", uid = 99, timestampMillis = 999, inInbox = false),
message("AR", uid = 5, timestampMillis = 50, folder = "Archive"),
),
)
// Keep the newest 2 by UID (recent-old-date, A); the rest are prunable. A Date-ordered keep would
// wrongly evict recent-old-date (oldest Date) and keep B.
assertEquals(
setOf("B", "D"),
dao.syncedIdsBeyondCountInFolder("acct", "INBOX", keep = 2).toSet(),
)
// Keeping at least as many as exist prunes nothing.
assertEquals(emptyList<String>(), dao.syncedIdsBeyondCountInFolder("acct", "INBOX", keep = 4))
}
/**
* The age-based prune boundary selects rows STRICTLY older than the cutoff, across every folder,
* scoped to the account and to synced (non-search) rows only.
*/
@Test
fun syncedIdsOlderThanCutsStrictlyBelowAcrossFoldersAndScopesToAccount() = runBlocking {
dao.insertNew(
listOf(
message("boundary", uid = 25, timestampMillis = 200), // == cutoff -> kept (strict `<`)
message("old-inbox", uid = 10, timestampMillis = 100),
message("old-archive", uid = 5, timestampMillis = 150, folder = "Archive"),
message("old-search", uid = 1, timestampMillis = 1, inInbox = false), // not synced
message("old-other-account", accountId = "acct2", uid = 1, timestampMillis = 1),
),
)
assertEquals(
setOf("old-inbox", "old-archive"),
dao.syncedIdsOlderThan("acct", cutoffMillis = 200).toSet(),
)
}
/**
* The windowed reconcile deletes only synced rows at/above the recent-UID window that the server no
* longer returns; older backfilled history (below the window), other folders, and search rows are
* left intact — the core guarantee that a foreground sync no longer wipes backfilled history (#12).
*/
@Test
fun deleteSyncedInWindowNotInSparesBelowWindowHistoryAndOtherFolders() = runBlocking {
dao.insertNew(
listOf(
message("below", uid = 10), // below the window -> spared
message("kept", uid = 20), // in window, in keep set -> spared
message("gone-1", uid = 30), // in window, not kept -> deleted
message("gone-2", uid = 40), // in window, not kept -> deleted
message("search", uid = 22, inInbox = false), // not synced -> spared
message("other-folder", uid = 25, folder = "Archive"), // different folder -> spared
),
)
dao.deleteSyncedInWindowNotIn("acct", "INBOX", minWindowUid = 20, keepIds = listOf("kept"))
// Read survivors back with point lookups (getById) rather than observeAll(): explicit about each
// row's fate, and it keeps the assertion off the Flow API.
assertNull("gone-1 is in-window and unkept -> deleted", dao.getById("gone-1"))
assertNull("gone-2 is in-window and unkept -> deleted", dao.getById("gone-2"))
assertNotNull("below-window history must survive", dao.getById("below"))
assertNotNull("the kept row must survive", dao.getById("kept"))
assertNotNull("search rows are not synced -> untouched", dao.getById("search"))
assertNotNull("other folders are untouched", dao.getById("other-folder"))
}
/**
* The backfiller's floor probes reflect only an account's synced rows in the given folder, and are
* null/zero for a folder with nothing cached (so the backfiller then starts from `Long.MAX_VALUE`).
* The paging boundary additionally skips `uid <= 0` placeholder rows (#95): a row migrated before
* the `uid` column existed (backfilled to 0) or one whose UID the server failed to resolve (-1)
* must not collapse MIN(uid) to a bound the backfiller treats as "folder fully paged".
*/
@Test
fun floorProbesReflectOnlySyncedRowsInTheFolder() = runBlocking {
dao.insertNew(
listOf(
message("a", uid = 30, timestampMillis = 300),
message("d", uid = 10, timestampMillis = 100),
message("legacy", uid = 0, timestampMillis = 40), // pre-uid-column migration row (#95)
message("unresolved", uid = -1, timestampMillis = 30), // UIDFolder.getUID failure (#95)
message("search", uid = 1, timestampMillis = 1, inInbox = false), // excluded
message("archive", uid = 5, timestampMillis = 50, folder = "Archive"), // different folder
),
)
assertEquals(10L, dao.lowestSyncedUid("acct", "INBOX"))
assertEquals(4, dao.countSynced("acct", "INBOX"))
assertNull(dao.lowestSyncedUid("acct", "Nonexistent"))
assertEquals(0, dao.countSynced("acct", "Nonexistent"))
// A folder holding ONLY placeholder rows has no usable boundary: null (start from the top).
dao.insertNew(listOf(message("only-legacy", uid = 0, folder = "Imported")))
assertNull(dao.lowestSyncedUid("acct", "Imported"))
}
/** Backfill / prune enumerate their targets via [syncedFolders]: distinct synced folders, per account. */
@Test
fun syncedFoldersReturnsDistinctSyncedFoldersForTheAccount() = runBlocking {
dao.insertNew(
listOf(
message("i1", folder = "INBOX"),
message("i2", folder = "INBOX"),
message("ar", folder = "Archive"),
message("sr", folder = "Search", inInbox = false), // search-only folder excluded
message("other", accountId = "acct2", folder = "Spam"), // other account excluded
),
)
assertEquals(setOf("INBOX", "Archive"), dao.syncedFolders("acct").toSet())
}
}
@@ -0,0 +1,118 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.data.local
import android.content.Context
import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.libremail.data.local.dao.MessageDao
import org.libremail.data.local.entity.MessageEntity
/**
* Real-SQLite behavior of the body-less routing projection [MessageDao.getRouting] /
* [MessageDao.getRoutingByIds] (issue #186). The open path and the flag/move callers route on these
* instead of pulling the whole `body` blob through [MessageDao.getById]. These tests pin that the
* projection maps every routing/flag column correctly (and can do so for a row whose body is large,
* which is exactly the over-fetch the projection avoids).
*/
@RunWith(AndroidJUnit4::class)
class MessageDaoRoutingTest {
private lateinit var db: LibreMailDatabase
private lateinit var dao: MessageDao
@Before
fun setUp() {
val context = ApplicationProvider.getApplicationContext<Context>()
db = Room.inMemoryDatabaseBuilder(context, LibreMailDatabase::class.java).build()
dao = db.messageDao()
}
@After
fun tearDown() = db.close()
@Suppress("LongParameterList")
private fun message(
id: String,
accountId: String = "acct",
folder: String = "INBOX",
uid: Long = 0L,
isRead: Boolean = false,
isStarred: Boolean = false,
bodyFetched: Boolean = false,
isHtml: Boolean = false,
body: String = "",
) = MessageEntity(
id = id,
accountId = accountId,
sender = "Ada",
senderEmail = "ada@example.org",
subject = "Hi",
snippet = "",
body = body,
isHtml = isHtml,
timestampMillis = 1_000L,
isRead = isRead,
isStarred = isStarred,
folder = folder,
bodyFetched = bodyFetched,
uid = uid,
)
@Test
fun getRoutingProjectsEveryRoutingAndFlagColumn() = runBlocking {
dao.insertNew(
listOf(
message(
id = "acct:Archive:9",
folder = "Archive",
uid = 9,
isRead = true,
isStarred = true,
bodyFetched = true,
isHtml = true,
body = "x".repeat(4_000), // a large body the projection must not need to read
),
),
)
val routing = requireNotNull(dao.getRouting("acct:Archive:9"))
assertEquals("acct:Archive:9", routing.id)
assertEquals("acct", routing.accountId)
assertEquals("Archive", routing.folder)
assertEquals(9L, routing.uid)
assertEquals(true, routing.isRead)
assertEquals(true, routing.isStarred)
assertEquals(true, routing.bodyFetched)
assertEquals(true, routing.isHtml)
}
@Test
fun getRoutingIsNullForAnUnknownId() = runBlocking {
assertNull(dao.getRouting("acct:INBOX:404"))
}
@Test
fun getRoutingByIdsReturnsOnlyTheRequestedRows() = runBlocking {
dao.insertNew(
listOf(
message(id = "acct:INBOX:1", folder = "INBOX", uid = 1),
message(id = "acct:INBOX:2", folder = "INBOX", uid = 2),
message(id = "acct2:Sent:3", folder = "Sent", accountId = "acct2", uid = 3),
),
)
val routings = dao.getRoutingByIds(listOf("acct:INBOX:1", "acct2:Sent:3"))
assertEquals(setOf("acct:INBOX:1", "acct2:Sent:3"), routings.map { it.id }.toSet())
assertEquals(setOf("INBOX", "Sent"), routings.map { it.folder }.toSet())
}
}
@@ -0,0 +1,85 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.data.local
import androidx.room.testing.MigrationTestHelper
import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
/**
* Validates the v12 -> v13 migration (issues #12/#13): `messages.uid` is added and backfilled from the
* id's numeric tail, `account_settings` gains the nullable retention overrides, and the
* `backfill_progress` table is created. `runMigrationsAndValidate` additionally checks the whole
* migrated schema matches the exported v13 schema.
*/
@RunWith(AndroidJUnit4::class)
class Migration12To13Test {
@get:Rule
val helper = MigrationTestHelper(
InstrumentationRegistry.getInstrumentation(),
LibreMailDatabase::class.java,
emptyList(),
FrameworkSQLiteOpenHelperFactory(),
)
@Test
fun migrate12To13_backfillsUidAndAddsRetentionAndBackfillTables() {
helper.createDatabase(DB_NAME, 12).apply {
// A plain inbox row, a folder name containing special characters, and a folder name that
// ends in a digit — all must recover the trailing UID correctly.
insertV12Message("acct:INBOX:42")
insertV12Message("acct:[Gmail]/Sent Mail:7")
insertV12Message("acct:Folder2:15")
close()
}
val db = helper.runMigrationsAndValidate(DB_NAME, 13, true, MIGRATION_12_13)
// uid is materialized from the id's numeric tail.
assertEquals(42L, uidOf(db, "acct:INBOX:42"))
assertEquals(7L, uidOf(db, "acct:[Gmail]/Sent Mail:7"))
assertEquals(15L, uidOf(db, "acct:Folder2:15"))
// The new retention columns exist and default to NULL (= inherit the global default).
db.query("SELECT retentionCount, retentionMonths FROM account_settings").use { c ->
// No rows required; the query succeeding proves the columns exist.
assertTrue(c.columnCount == 2)
}
// The backfill_progress table exists and accepts a row.
db.execSQL(
"INSERT INTO backfill_progress (accountId, folder, nextBeforeUid, complete) " +
"VALUES ('acct', 'INBOX', 41, 0)",
)
db.query("SELECT nextBeforeUid FROM backfill_progress WHERE accountId='acct' AND folder='INBOX'").use { c ->
assertTrue(c.moveToFirst())
assertEquals(41L, c.getLong(0))
}
db.close()
}
private fun uidOf(db: androidx.sqlite.db.SupportSQLiteDatabase, id: String): Long =
db.query("SELECT uid FROM messages WHERE id = ?", arrayOf<Any>(id)).use { c ->
assertTrue("row $id must exist", c.moveToFirst())
c.getLong(0)
}
private fun androidx.sqlite.db.SupportSQLiteDatabase.insertV12Message(id: String) {
execSQL(
"INSERT INTO messages (id, accountId, sender, senderEmail, subject, snippet, body, isHtml, " +
"timestampMillis, isRead, isStarred, folder, inInbox, bodyFetched) " +
"VALUES (?, 'acct', 'Ada', 'ada@example.org', 'Hi', '', '', 0, 1000, 0, 0, 'INBOX', 1, 0)",
arrayOf<Any>(id),
)
}
private companion object {
const val DB_NAME = "migration-12-13-test.db"
}
}
@@ -0,0 +1,393 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.data.local
import androidx.room.migration.Migration
import androidx.room.testing.MigrationTestHelper
import androidx.sqlite.db.SupportSQLiteDatabase
import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import java.lang.reflect.Modifier
/**
* Replays the app's Room migrations against the schema JSONs exported to `app/schemas` (shipped to
* the test APK as assets). DatabaseModule deliberately registers no destructive fallback — dropping
* tables would lose stored accounts and credentials — so a drifted migration crashes upgrading
* users at first database open; these tests make such drift fail in CI instead (issue #63).
*
* The migration list is discovered from Migrations.kt and the target version from the newest
* exported schema, so a future migration is covered automatically: author the `Migration`, register
* it in DatabaseModule, commit the new schema JSON, and [migratingFromV7ReplaysEveryMigrationAndPreservesData]
* replays it with no edit here. (Only v7+ can be replayed — older versions predate schema export.)
*/
@RunWith(AndroidJUnit4::class)
class MigrationTest {
@get:Rule
val helper = MigrationTestHelper(
InstrumentationRegistry.getInstrumentation(),
LibreMailDatabase::class.java,
emptyList(),
FrameworkSQLiteOpenHelperFactory(),
)
/** v11 -> v12 (PR #54): `folders.specialUse` appears defaulting to 0 and existing data survives. */
@Test
fun migrate11To12_defaultsExistingFoldersToNotSpecialUse() {
helper.createDatabase(TEST_DB, 11).apply {
insertAccount()
execSQL(
"INSERT INTO folders (accountId, fullName, displayName, role, selectable, sortOrder) VALUES " +
"('acct', 'INBOX', 'INBOX', 'INBOX', 1, 0), " +
"('acct', '[Gmail]/Sent Mail', 'Sent Mail', 'SENT', 1, 1)",
)
execSQL(
"INSERT INTO messages (id, accountId, sender, senderEmail, subject, snippet, body, isHtml, " +
"timestampMillis, isRead, isStarred, folder, inInbox, bodyFetched) " +
"VALUES ('acct:INBOX:1', 'acct', 'Ada', 'ada@example.org', 'Hi', '', '', 0, 1000, 0, 0, " +
"'INBOX', 1, 0)",
)
close()
}
val db = helper.runMigrationsAndValidate(TEST_DB, 12, true, MIGRATION_11_12)
db.query("SELECT fullName, specialUse FROM folders ORDER BY sortOrder").use { c ->
assertTrue(c.moveToFirst())
assertEquals("INBOX", c.getString(0))
assertEquals(0, c.getInt(1))
assertTrue(c.moveToNext())
assertEquals("[Gmail]/Sent Mail", c.getString(0))
assertEquals(0, c.getInt(1))
assertFalse("both pre-upgrade folder rows must survive, and nothing else", c.moveToNext())
}
// The folders' account and cached mail are untouched.
assertEquals(1, db.count("accounts"))
assertEquals(1, db.count("messages"))
db.close()
}
/** v14 -> v15 (issue #66): `folders.hierarchyDelimiter` appears defaulting to NULL; data survives. */
@Test
fun migrate14To15_addsNullHierarchyDelimiterToFolders() {
helper.createDatabase(TEST_DB, 14).apply {
insertAccount()
execSQL(
"INSERT INTO folders (accountId, fullName, displayName, role, selectable, sortOrder, specialUse) " +
"VALUES ('acct', 'INBOX', 'INBOX', 'INBOX', 1, 0, 0), " +
"('acct', 'Work.Reports', 'Reports', 'NORMAL', 1, 1, 0)",
)
close()
}
val db = helper.runMigrationsAndValidate(TEST_DB, 15, true, MIGRATION_14_15)
db.query("SELECT fullName, hierarchyDelimiter FROM folders ORDER BY sortOrder").use { c ->
assertTrue(c.moveToFirst())
assertEquals("INBOX", c.getString(0))
assertTrue("existing folders read a null delimiter until the next refresh", c.isNull(1))
assertTrue(c.moveToNext())
assertEquals("Work.Reports", c.getString(0))
assertTrue(c.isNull(1))
assertFalse("both pre-upgrade folder rows must survive, and nothing else", c.moveToNext())
}
// The folders' account is untouched.
assertEquals(1, db.count("accounts"))
db.close()
}
/**
* A gap in the chain (or a migration whose target schema was never committed) crashes upgrading
* users, so fail fast with a readable message before replaying anything.
*/
@Test
fun migrationsFormOneUnbrokenChainUpToTheLatestExportedSchema() {
assertEquals(
"Migrations.kt must chain every version step up to the newest exported schema " +
"(DatabaseModule registers no destructive fallback, so a gap crashes upgrades)",
(1 until latestExportedSchemaVersion()).map { it to it + 1 },
allAppMigrations.map { it.startVersion to it.endVersion },
)
}
/**
* Creates a database at v7 (the oldest exported schema), fills it like a used install, then
* replays every migration one step at a time — `runMigrationsAndValidate` diffs the migrated
* schema against each version's exported JSON, so a failure names the exact step that drifted.
*/
@Test
fun migratingFromV7ReplaysEveryMigrationAndPreservesData() {
helper.createDatabase(TEST_DB, OLDEST_EXPORTED_SCHEMA).apply {
seedVersion7Cache()
close()
}
var open: SupportSQLiteDatabase? = null
for (migration in allAppMigrations.filter { it.startVersion >= OLDEST_EXPORTED_SCHEMA }) {
open?.close()
val stepDb = helper.runMigrationsAndValidate(TEST_DB, migration.endVersion, true, migration)
stepDb.writeMidChainData()
// v16 moves the account tables out to AccountDatabase and drops them, so assert their rows
// and backfills reached v15 intact — just before the move (issue #111).
if (stepDb.version == 15) stepDb.assertAccountDataPresentAtV15()
open = stepDb
}
val db = checkNotNull(open) { "no migration starts at v$OLDEST_EXPORTED_SCHEMA" }
assertEquals("the chain must end at the newest exported schema", latestExportedSchemaVersion(), db.version)
db.assertVersion7CacheSurvived()
db.assertMigrationBackfillsApplied()
db.assertAccountTablesDroppedAtV16()
db.close()
}
/** v15 -> v16 (issue #111): the moved account tables are dropped and the mail cache is untouched. */
@Test
fun migrate15To16_dropsMovedAccountTablesAndKeepsCache() {
helper.createDatabase(TEST_DB, 15).apply {
insertAccount()
execSQL("INSERT INTO credentials (accountId, encryptedSecret) VALUES ('acct', 'sealed')")
execSQL(
"INSERT INTO messages (id, accountId, sender, senderEmail, subject, snippet, body, isHtml, " +
"timestampMillis, isRead, isStarred, folder, inInbox, bodyFetched, uid) VALUES " +
"('acct:INBOX:1', 'acct', 'Ada', 'ada@example.org', 'Hi', '', '', 0, 1000, 0, 0, " +
"'INBOX', 1, 0, 1)",
)
close()
}
val db = helper.runMigrationsAndValidate(TEST_DB, 16, true, MIGRATION_15_16)
db.assertAccountTablesDroppedAtV16()
assertEquals("the mail cache must be untouched by 15->16", 1, db.count("messages"))
db.close()
}
/** v16 -> v17 (issue #133): `attachments.contentId` appears defaulting to NULL; cached rows survive. */
@Test
fun migrate16To17_addsNullContentIdToAttachments() {
helper.createDatabase(TEST_DB, 16).apply {
// v16 dropped the account tables, so a message (no FK to accounts) plus its attachment is
// all that's needed to exercise the attachments table rebuild.
execSQL(
"INSERT INTO messages (id, accountId, sender, senderEmail, subject, snippet, body, isHtml, " +
"timestampMillis, isRead, isStarred, folder, inInbox, bodyFetched, uid) VALUES " +
"('acct:INBOX:1', 'acct', 'Ada', 'ada@example.org', 'Hi', '', '', 0, 1000, 0, 0, " +
"'INBOX', 1, 1, 1)",
)
execSQL(
"INSERT INTO attachments (messageId, partIndex, filename, mimeType, sizeBytes) " +
"VALUES ('acct:INBOX:1', 0, 'report.pdf', 'application/pdf', 2048)",
)
close()
}
val db = helper.runMigrationsAndValidate(TEST_DB, 17, true, MIGRATION_16_17)
db.query("SELECT filename, contentId FROM attachments WHERE messageId = 'acct:INBOX:1'").use { c ->
assertTrue("the pre-upgrade attachment row must survive", c.moveToFirst())
assertEquals("report.pdf", c.getString(0))
assertTrue("existing attachments read a null contentId (treated as ordinary downloads)", c.isNull(1))
assertFalse("only the one pre-upgrade attachment row must survive", c.moveToNext())
}
assertEquals("the cached message must be untouched by 16->17", 1, db.count("messages"))
db.close()
}
/** v17 -> v18 (issue #77): `outbox.attachments` appears defaulting to '' and the queued row survives. */
@Test
fun migrate17To18_addsEmptyAttachmentsToOutbox() {
helper.createDatabase(TEST_DB, 17).apply {
// v16 dropped the account tables, so a bare outbox row is all this migration needs.
execSQL(
"INSERT INTO outbox (id, accountId, toAddresses, ccAddresses, bccAddresses, subject, body, " +
"createdAt, lastError, bodyHtml) VALUES ('out-1', 'acct', 'bob@example.org', '', '', " +
"'Queued', 'Body', 3000, NULL, NULL)",
)
close()
}
val db = helper.runMigrationsAndValidate(TEST_DB, 18, true, MIGRATION_17_18)
db.query("SELECT subject, attachments FROM outbox WHERE id = 'out-1'").use { c ->
assertTrue("the queued outbox row must survive", c.moveToFirst())
assertEquals("Queued", c.getString(0))
assertEquals("existing outbox rows read empty attachment metadata", "", c.getString(1))
assertFalse("only the one pre-upgrade outbox row must survive", c.moveToNext())
}
db.close()
}
/** The newest schema JSON exported to app/schemas (shipped to the test APK as assets). */
private fun latestExportedSchemaVersion(): Int {
val schemaFolder = checkNotNull(LibreMailDatabase::class.java.canonicalName)
val versions = InstrumentationRegistry.getInstrumentation().context.assets.list(schemaFolder)
.orEmpty()
.mapNotNull { it.removeSuffix(".json").toIntOrNull() }
check(versions.isNotEmpty()) { "no exported schemas under androidTest assets/$schemaFolder" }
return versions.max()
}
/** The accounts table has kept this shape since before v7, so every test can share one insert. */
private fun SupportSQLiteDatabase.insertAccount() {
execSQL(
"INSERT INTO accounts (id, email, displayName, authType, imap_host, imap_port, imap_security, " +
"smtp_host, smtp_port, smtp_security) VALUES ('acct', 'ada@example.org', 'Ada', " +
"'PASSWORD_IMAP', 'imap.example.org', 993, 'SSL_TLS', 'smtp.example.org', 465, 'SSL_TLS')",
)
}
/** Fills a v7 database the way a used install would look (columns exactly as in 7.json). */
private fun SupportSQLiteDatabase.seedVersion7Cache() {
insertAccount()
execSQL("INSERT INTO credentials (accountId, encryptedSecret) VALUES ('acct', 'sealed-secret')")
execSQL(
"INSERT INTO messages (id, accountId, sender, senderEmail, subject, snippet, body, isHtml, " +
"timestampMillis, isRead, isStarred, inInbox, bodyFetched) VALUES " +
"('acct:1', 'acct', 'Ada', 'ada@example.org', 'Analytical engines', 'Dear Charles', " +
"'Dear Charles, the mill works.', 0, 1000, 1, 0, 1, 1), " +
"('acct:2', 'acct', 'Charles', 'charles@example.org', 'Re: engines', '', '', 0, 2000, 0, 1, 1, 0)",
)
execSQL(
"INSERT INTO attachments (messageId, partIndex, filename, mimeType, sizeBytes) " +
"VALUES ('acct:1', 0, 'notes.pdf', 'application/pdf', 2048)",
)
execSQL(
"INSERT INTO outbox (id, accountId, toAddresses, ccAddresses, subject, body, createdAt, " +
"lastError) VALUES ('out-1', 'acct', 'charles@example.org', '', 'Queued', 'Body', 3000, NULL)",
)
execSQL(
"INSERT INTO drafts (id, accountId, toAddresses, ccAddresses, subject, body, updatedAt, " +
"attachments) VALUES ('draft-1', 'acct', 'charles@example.org', '', 'Draft', 'Text', 4000, '')",
)
}
/** Rows a user would write at intermediate versions; they must survive the rest of the chain. */
private fun SupportSQLiteDatabase.writeMidChainData() {
when (version) {
// v8 is the first version with a folders table; 11->12 must stamp this row specialUse = 0.
8 -> execSQL(
"INSERT INTO folders (accountId, fullName, displayName, role, selectable, sortOrder) " +
"VALUES ('acct', 'INBOX', 'INBOX', 'INBOX', 1, 0)",
)
// A signature saved by a v9 user: 10->11 must carry it into the new signatures table.
9 -> execSQL(
"UPDATE account_settings SET signature = 'Cheers,' || char(10) || 'Ada' " +
"WHERE accountId = 'acct'",
)
}
}
/** Every mail-cache row cached at v7 must survive to v16 (account tables are checked separately). */
private fun SupportSQLiteDatabase.assertVersion7CacheSurvived() {
assertEquals(2, count("messages"))
assertEquals(1, count("outbox"))
assertEquals(1, count("drafts"))
query("SELECT folder, subject, isRead FROM messages WHERE id = 'acct:1'").use { c ->
assertTrue("message cached at v7 must survive", c.moveToFirst())
assertEquals("7->8 files pre-upgrade messages under INBOX", "INBOX", c.getString(0))
assertEquals("Analytical engines", c.getString(1))
assertEquals(1, c.getInt(2))
}
query("SELECT filename FROM attachments WHERE messageId = 'acct:1'").use { c ->
assertTrue("attachment rows must survive the 6->7 style table rebuilds", c.moveToFirst())
assertEquals("notes.pdf", c.getString(0))
}
}
/** Cache-table columns/rows the migrations backfill must hold their documented defaults at v16. */
private fun SupportSQLiteDatabase.assertMigrationBackfillsApplied() {
// 9->10 adds bcc columns defaulting to ''; 10->11 adds nullable bodyHtml.
query("SELECT bccAddresses, bodyHtml FROM outbox WHERE id = 'out-1'").use { c ->
assertTrue(c.moveToFirst())
assertEquals("", c.getString(0))
assertTrue("bodyHtml must default to null (plaintext-only)", c.isNull(1))
}
query("SELECT bccAddresses, bodyHtml FROM drafts WHERE id = 'draft-1'").use { c ->
assertTrue(c.moveToFirst())
assertEquals("", c.getString(0))
assertTrue(c.isNull(1))
}
// 11->12 stamps the folder cached at v8 as not special-use.
query("SELECT specialUse FROM folders WHERE fullName = 'INBOX'").use { c ->
assertTrue("folder cached at v8 must survive to the newest version", c.moveToFirst())
assertEquals(0, c.getInt(0))
}
// 14->15 adds a nullable hierarchyDelimiter; the folder cached at v8 predates it, so it reads
// null and the drawer infers the separator from the name until the next folder refresh.
query("SELECT hierarchyDelimiter FROM folders WHERE fullName = 'INBOX'").use { c ->
assertTrue(c.moveToFirst())
assertTrue("a folder cached before v15 must read a null delimiter", c.isNull(0))
}
}
/**
* The account tables' rows + migration backfills must be intact at v15, just before 15->16 moves
* them to [AccountDatabase] and drops them (issue #111). AccountDataMigrator's own copy is
* exercised in `AccountDataMigratorTest`; here we only assert the source rows reach the move point.
*/
private fun SupportSQLiteDatabase.assertAccountDataPresentAtV15() {
assertEquals(1, count("accounts"))
query("SELECT encryptedSecret FROM credentials WHERE accountId = 'acct'").use { c ->
assertTrue("stored credentials must reach v15 before the move", c.moveToFirst())
assertEquals("sealed-secret", c.getString(0))
}
// 8->9 backfills one default settings row per existing account.
query("SELECT signatureEnabled, notificationsEnabled FROM account_settings").use { c ->
assertTrue("8->9 must backfill a settings row for the v7 account", c.moveToFirst())
assertEquals(1, c.getInt(0))
assertEquals(1, c.getInt(1))
}
// 10->11 turns the signature written at v9 into that account's default rich-text signature.
query("SELECT name, contentHtml, isDefault FROM signatures WHERE accountId = 'acct'").use { c ->
assertTrue("10->11 must backfill the legacy per-account signature", c.moveToFirst())
assertEquals("Signature", c.getString(0))
assertEquals("Cheers,<br>Ada", c.getString(1))
assertEquals(1, c.getInt(2))
assertFalse("exactly one signature row must be backfilled", c.moveToNext())
}
}
/** 15->16 drops the account tables from the cache (AccountDataMigrator copies them out first). */
private fun SupportSQLiteDatabase.assertAccountTablesDroppedAtV16() {
listOf("accounts", "credentials", "account_settings", "signatures").forEach { table ->
query("SELECT name FROM sqlite_master WHERE type = 'table' AND name = '$table'").use { c ->
assertFalse("15->16 must drop `$table` from the cache database", c.moveToFirst())
}
}
}
private fun SupportSQLiteDatabase.count(table: String): Int = query("SELECT COUNT(*) FROM $table").use { c ->
c.moveToFirst()
c.getInt(0)
}
private companion object {
const val TEST_DB = "migration-test.db"
/** The oldest schema in app/schemas; earlier versions predate schema export. */
const val OLDEST_EXPORTED_SCHEMA = 7
/**
* Every migration the app ships, pulled from Migrations.kt's file facade so a newly added
* migration is replayed automatically. DatabaseModule.provideDatabase registers this same
* set of top-level vals.
*/
val allAppMigrations: List<Migration> = run {
val migrationsFile = checkNotNull(MIGRATION_1_2.javaClass.enclosingClass) {
"expected MIGRATION_1_2 to be a top-level val in Migrations.kt"
}
migrationsFile.methods
.filter { Modifier.isStatic(it.modifiers) && it.parameterCount == 0 }
.filter { Migration::class.java.isAssignableFrom(it.returnType) }
.map { it.invoke(null) as Migration }
.sortedBy { it.startVersion }
}
}
}
@@ -3,10 +3,10 @@ package org.libremail.mail
import androidx.test.ext.junit.runners.AndroidJUnit4
import jakarta.mail.Session
import java.util.Properties
import org.junit.Assert.assertNotNull
import org.junit.Test
import org.junit.runner.RunWith
import java.util.Properties
/**
* Guards the main "Jakarta/Angus Mail on Android" risk: that provider registration
@@ -0,0 +1,51 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.notifications
import android.content.Intent
import android.net.Uri
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
/**
* Locks in the notification deep-link contract: a message id round-trips build → parse, and intents
* for different messages are distinct under [Intent.filterEquals] — the identity PendingIntent keys
* on — so per-message notifications never collapse onto one shared PendingIntent.
*/
@RunWith(AndroidJUnit4::class)
class NotificationIntentsTest {
private val context = InstrumentationRegistry.getInstrumentation().targetContext
@Test
fun message_id_round_trips_through_the_intent() {
val id = "imap:user@example.com:INBOX:42"
assertEquals(id, NotificationIntents.messageId(NotificationIntents.openMessage(context, id)))
}
@Test
fun uri_hostile_ids_round_trip() {
val id = "imap:user@example.com:[Gmail]/All Mail:7?&%#"
assertEquals(id, NotificationIntents.messageId(NotificationIntents.openMessage(context, id)))
}
@Test
fun other_intents_carry_no_message_id() {
assertNull(NotificationIntents.messageId(null))
assertNull(NotificationIntents.messageId(Intent(Intent.ACTION_MAIN)))
assertNull(NotificationIntents.messageId(Intent(Intent.ACTION_VIEW, Uri.parse("mailto:a@b.c"))))
}
@Test
fun intents_for_different_messages_are_distinct_pending_intent_keys() {
val first = NotificationIntents.openMessage(context, "imap:a@b:INBOX:1")
val second = NotificationIntents.openMessage(context, "imap:a@b:INBOX:2")
assertFalse(first.filterEquals(second))
assertTrue(first.filterEquals(NotificationIntents.openMessage(context, "imap:a@b:INBOX:1")))
}
}
@@ -0,0 +1,48 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.push
import android.content.Context
import android.net.Uri
import android.provider.Settings
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
/**
* Exercises the real [BatteryOptimizationManager.candidateIntents]/[BatteryOptimizationManager.settingsIntent]
* against a real `Context`/`PackageManager` (#150): `Intent`/`Uri`/`PackageManager.resolveActivity` are
* unmocked SDK stubs off-device, so this on-device coverage is the only place the actual candidate
* actions, order, and package scoping can be checked directly. The fallback-selection logic itself
* (which candidate wins, and that there's always a last resort) is covered Android-free by
* `BatteryOptimizationManagerTest` in the `test` source set; end-to-end launch-from-the-onboarding-
* button coverage lives in `BatteryOptimizationStepTest`.
*/
@RunWith(AndroidJUnit4::class)
class BatteryOptimizationManagerIntentTest {
private val context = ApplicationProvider.getApplicationContext<Context>()
private val manager = BatteryOptimizationManager(context)
@Test
fun candidateIntents_tryAppDetailsFirst_thenTheBatteryOptimizationList() {
val candidates = manager.candidateIntents()
assertEquals(2, candidates.size)
assertEquals(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, candidates[0].action)
assertEquals(Uri.fromParts("package", context.packageName, null), candidates[0].data)
assertEquals(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS, candidates[1].action)
}
@Test
fun settingsIntent_landsOnAppDetails_becauseItAlwaysResolvesOnARealDevice() {
// Every real/emulated Android device ships a Settings app that resolves app-details for any
// installed package, so the primary (most direct) candidate always wins here. The fallback
// exists for devices this test environment can't represent (see the class KDoc).
val intent = manager.settingsIntent()
assertEquals(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, intent.action)
assertEquals(Uri.fromParts("package", context.packageName, null), intent.data)
}
}
@@ -1,19 +1,25 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.ui
import java.io.File
import androidx.paging.PagingData
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flowOf
import org.libremail.data.sync.Syncer
import org.libremail.domain.model.Account
import org.libremail.domain.model.Attachment
import org.libremail.domain.model.Draft
import org.libremail.domain.model.Folder
import org.libremail.domain.model.ImapConnectionParams
import org.libremail.domain.model.InlineImage
import org.libremail.domain.model.Message
import org.libremail.domain.model.OutboxMessage
import org.libremail.domain.model.OutgoingMessage
import org.libremail.domain.model.ReplyMode
import org.libremail.domain.model.UnreadCount
import org.libremail.domain.repository.AccountRepository
import org.libremail.domain.repository.MailRepository
import java.io.File
/**
* In-memory [AccountRepository] for Compose UI tests: serves a fixed account list, records the
@@ -38,6 +44,11 @@ class FakeAccountRepository(
override suspend fun addImapAccount(account: Account, password: String): Result<List<String>> {
addedAccount = account
addedPassword = password
// Mirror the real repository: a successful add makes the account observable, so screens that
// react to the account list (e.g. the mailbox after onboarding) see it appear.
if (result.isSuccess) {
accountsFlow.value = accountsFlow.value.filterNot { it.id == account.id } + account
}
return result
}
@@ -50,6 +61,8 @@ class FakeAccountRepository(
override suspend fun deleteAccount(id: String) {
accountsFlow.value = accountsFlow.value.filterNot { it.id == id }
}
override suspend fun resetBackfillProgress(accountId: String?) = Unit
}
/**
@@ -58,28 +71,99 @@ class FakeAccountRepository(
*/
class FakeMailRepository(
var sendResult: Result<Unit> = Result.success(Unit),
private val messages: List<Message> = emptyList(),
private val folders: List<Folder> = emptyList(),
private val attachments: List<Attachment> = emptyList(),
private val downloadedParts: Set<Int> = emptySet(),
private val unreadCounts: List<UnreadCount> = emptyList(),
) : MailRepository {
val sentMessages = mutableListOf<OutgoingMessage>()
val savedDrafts = mutableListOf<Draft>()
val deletedDraftIds = mutableListOf<String>()
val archivedIds = mutableListOf<List<String>>()
val spammedIds = mutableListOf<List<String>>()
val trashedIds = mutableListOf<List<String>>()
val expungedIds = mutableListOf<List<String>>()
val movedToFolder = mutableListOf<Pair<List<String>, String>>()
val replyDrafts = mutableListOf<Pair<String, ReplyMode>>()
override fun observeMessages(): Flow<List<Message>> = flowOf(emptyList())
override fun observeFolderMessages(accountId: String, folder: String): Flow<List<Message>> =
flowOf(messages.filter { it.accountId == accountId && it.folder == folder })
override suspend fun getMessage(id: String): Message? = null
override fun observeUnifiedFolderMessages(folder: String): Flow<List<Message>> =
flowOf(messages.filter { it.folder == folder })
override fun pagedUnifiedFolderMessages(folder: String): Flow<PagingData<Message>> =
flowOf(PagingData.from(messages.filter { it.folder == folder && it.inInbox }))
override fun observeFolders(accountId: String): Flow<List<Folder>> = flowOf(
folders.filter {
it.accountId ==
accountId
},
)
override fun observeUnreadCounts(): Flow<List<UnreadCount>> = flowOf(unreadCounts)
override suspend fun refreshFolders(accountId: String): Result<Unit> = Result.success(Unit)
override suspend fun getMessage(id: String): Message? = messages.firstOrNull { it.id == id }
override suspend fun openMessage(id: String): Result<Message> =
Result.failure(UnsupportedOperationException("not used in UI tests"))
messages.firstOrNull { it.id == id }?.let { Result.success(it) }
?: Result.failure(NoSuchElementException("no message $id"))
override fun observeAttachments(messageId: String): Flow<List<Attachment>> = flowOf(emptyList())
override fun observeAttachments(messageId: String): Flow<List<Attachment>> = flowOf(
attachments.filter {
it.messageId ==
messageId
},
)
override suspend fun inlineImages(messageId: String): List<InlineImage> = emptyList()
override suspend fun downloadAttachment(messageId: String, partIndex: Int): Result<File> =
Result.failure(UnsupportedOperationException("not used in UI tests"))
override suspend fun prefetchMessage(messageId: String): Result<Unit> = Result.success(Unit)
override suspend fun downloadedAttachmentParts(messageId: String): Set<Int> = downloadedParts
override suspend fun setStarred(id: String, starred: Boolean): Result<Unit> = Result.success(Unit)
override suspend fun deleteMessage(id: String): Result<Unit> = Result.success(Unit)
override suspend fun archive(ids: List<String>): Result<Unit> {
archivedIds += ids
return Result.success(Unit)
}
override suspend fun reportSpam(ids: List<String>): Result<Unit> {
spammedIds += ids
return Result.success(Unit)
}
override suspend fun trash(ids: List<String>): Result<Unit> {
trashedIds += ids
return Result.success(Unit)
}
override suspend fun expunge(ids: List<String>): Result<Unit> {
expungedIds += ids
return Result.success(Unit)
}
override suspend fun moveToFolder(ids: List<String>, destFolderFullName: String): Result<Unit> {
movedToFolder += ids to destFolderFullName
return Result.success(Unit)
}
override suspend fun buildReplyDraft(messageId: String, mode: ReplyMode): Result<String> {
replyDrafts += messageId to mode
return Result.success("draft-$messageId")
}
override suspend fun sendMessage(outgoing: OutgoingMessage): Result<Unit> {
sentMessages += outgoing
return sendResult
@@ -103,7 +187,14 @@ class FakeMailRepository(
override suspend fun retryOutbox() {}
override suspend fun searchServer(query: String) {}
override suspend fun searchServer(query: String, accountId: String?, folder: String) {}
override suspend fun clearSearchResults() {}
}
/** No-op [Syncer] for UI tests: the screen renders cached data, so sync calls do nothing. */
class FakeMailSyncer : Syncer {
override suspend fun syncAll(): Result<Int> = Result.success(0)
override suspend fun syncAccount(accountId: String): Result<Int> = Result.success(0)
override suspend fun syncFolder(accountId: String, folder: String): Result<Int> = Result.success(0)
}
@@ -35,7 +35,7 @@ class ManualSetupScreenTest {
// Build the view model once and capture it, so recomposition doesn't recreate it.
private fun setContent(
repository: FakeAccountRepository = FakeAccountRepository(),
onAccountAdded: () -> Unit = {},
onAccountAdded: (String) -> Unit = {},
) {
val viewModel = ManualSetupViewModel(repository)
composeTestRule.setContent {
@@ -3,23 +3,34 @@ package org.libremail.ui.compose
import android.Manifest
import androidx.activity.ComponentActivity
import androidx.compose.ui.semantics.SemanticsActions
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.hasSetTextAction
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTextInput
import androidx.lifecycle.SavedStateHandle
import androidx.room.Room
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.libremail.R
import org.libremail.contacts.ContactsRepository
import org.libremail.data.local.AccountDatabase
import org.libremail.data.settings.AccountSettingsRepository
import org.libremail.data.settings.SettingsRepository
import org.libremail.data.settings.SignatureRepository
import org.libremail.domain.model.Account
import org.libremail.domain.model.AuthType
import org.libremail.domain.model.MailSecurity
@@ -47,12 +58,20 @@ class ComposeScreenTest {
smtp = ServerConfig("smtp.example.com", 465, MailSecurity.SSL_TLS),
)
private var db: AccountDatabase? = null
private fun string(resId: Int) = composeTestRule.activity.getString(resId)
@After
fun closeDb() {
db?.close()
}
@Before
fun grantContactsPermission() {
// ComposeScreen requests READ_CONTACTS on first composition; pre-grant it (before the test
// calls setContent) so no system permission dialog appears to block the headless run.
// ComposeScreen no longer requests READ_CONTACTS (the request moved to onboarding/#127); it
// only reads the current grant on resume. Pre-grant it (before setContent) so contactsAllowed
// resolves true and the autocomplete path stays exercised — no system dialog is ever shown.
val instrumentation = InstrumentationRegistry.getInstrumentation()
instrumentation.uiAutomation.grantRuntimePermission(
instrumentation.targetContext.packageName,
@@ -61,16 +80,17 @@ class ComposeScreenTest {
}
// Build the view model once and capture it, so recomposition doesn't recreate it.
private fun setContent(
mailRepository: FakeMailRepository = FakeMailRepository(),
onBack: () -> Unit = {},
) {
private fun setContent(mailRepository: FakeMailRepository = FakeMailRepository(), onBack: () -> Unit = {}) {
val context = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext
val database = Room.inMemoryDatabaseBuilder(context, AccountDatabase::class.java).build().also { db = it }
val viewModel = ComposeViewModel(
savedStateHandle = SavedStateHandle(),
mailRepository = mailRepository,
accountRepository = FakeAccountRepository(accounts = listOf(account)),
contactsRepository = ContactsRepository(context),
accountSettingsRepository = AccountSettingsRepository(database.accountSettingsDao()),
signatureRepository = SignatureRepository(database.signatureDao()),
settingsRepository = SettingsRepository(context),
)
composeTestRule.setContent {
LibreMailTheme(darkTheme = false, dynamicColor = false) {
@@ -106,4 +126,105 @@ class ComposeScreenTest {
composeTestRule.waitUntil(timeoutMillis = 5_000) { closed }
}
@Test
fun send_whenBodyMentionsAttachmentWithoutOne_promptsBeforeSending() {
val mailRepository = FakeMailRepository()
setContent(mailRepository)
composeTestRule.onNodeWithText(string(R.string.compose_to)).performTextInput("you@example.com")
composeTestRule.onNodeWithText(string(R.string.compose_body)).performTextInput("I attached the report")
composeTestRule.onNodeWithContentDescription(string(R.string.action_send)).performClick()
// "Yes" returns to composing: the dialog closes and nothing is sent.
composeTestRule.onNodeWithText(string(R.string.confirm_attachment_title)).assertIsDisplayed()
composeTestRule.onNodeWithText(string(R.string.action_yes)).performClick()
composeTestRule.onNodeWithText(string(R.string.confirm_attachment_title)).assertDoesNotExist()
assertTrue(mailRepository.sentMessages.isEmpty())
// Sending again and answering "No" delivers the message as-is.
composeTestRule.onNodeWithContentDescription(string(R.string.action_send)).performClick()
composeTestRule.onNodeWithText(string(R.string.action_no)).performClick()
composeTestRule.waitUntil(timeoutMillis = 5_000) { mailRepository.sentMessages.isNotEmpty() }
assertEquals("I attached the report", mailRepository.sentMessages.single().body)
}
@Test
fun ccAndBcc_startCollapsed_expandViaLinksAndCarryThroughSend() {
val mailRepository = FakeMailRepository()
setContent(mailRepository)
// Collapsed: the Cc/Bcc labels exist only as links, not as editable fields.
editableField(R.string.compose_cc).assertDoesNotExist()
editableField(R.string.compose_bcc).assertDoesNotExist()
composeTestRule.onNodeWithText(string(R.string.compose_cc)).performClick()
editableField(R.string.compose_cc).performTextInput("cc@example.com")
composeTestRule.onNodeWithText(string(R.string.compose_bcc)).performClick()
editableField(R.string.compose_bcc).performTextInput("bcc@example.com")
composeTestRule.onNodeWithText(string(R.string.compose_to)).performTextInput("you@example.com")
composeTestRule.onNodeWithContentDescription(string(R.string.action_send)).performClick()
composeTestRule.waitUntil(timeoutMillis = 5_000) { mailRepository.sentMessages.isNotEmpty() }
val sent = mailRepository.sentMessages.single()
assertEquals("cc@example.com", sent.cc)
assertEquals("bcc@example.com", sent.bcc)
}
@Test
fun formattingToolbar_bulletButtonMarksTheLineAndSendsItAsHtml() {
val mailRepository = FakeMailRepository()
setContent(mailRepository)
composeTestRule.onNodeWithText(string(R.string.compose_to)).performTextInput("you@example.com")
composeTestRule.onNodeWithText(string(R.string.compose_body)).performTextInput("Buy milk")
// The bullet-list button is deliberately chosen over the inline styles (bold/italic): block
// markers apply to the caret's whole line, so the end-of-text caret that performTextInput
// leaves is enough - no on-device range selection (which is unreliable in instrumented tests)
// is needed to prove that a toolbar tap flows real formatting into the sent message's HTML.
// "•" is the bullet button's own (untranslated) glyph label - see FormattingToolbar.
composeTestRule.onNodeWithText("•").performClick()
composeTestRule.onNodeWithContentDescription(string(R.string.action_send)).performClick()
composeTestRule.waitUntil(timeoutMillis = 5_000) { mailRepository.sentMessages.isNotEmpty() }
val sent = mailRepository.sentMessages.single()
// Plaintext keeps the readable "• " marker; the HTML part carries the real <ul>/<li> structure.
assertEquals("• Buy milk", sent.body)
assertTrue(
"expected bullet-list html, got ${sent.bodyHtml}",
sent.bodyHtml?.contains("<ul><li>Buy milk</li></ul>") == true,
)
}
@Test
fun formattingToolbar_buttonsCarryOnClickLabelsForAccessibility() {
setContent()
// Every toolbar button (see FormattingToolbar in RichTextEditor.kt) is a plain clickable Box with
// a bare glyph Text as its only visible content, so TalkBack relies entirely on the click action's
// label (there is no separate contentDescription) to announce what the button does.
val buttons = listOf(
"B" to R.string.format_bold,
"I" to R.string.format_italic,
"U" to R.string.format_underline,
"•" to R.string.format_bullet_list,
"1." to R.string.format_numbered_list,
"❝" to R.string.format_quote,
"🔗" to R.string.format_link,
)
buttons.forEach { (glyph, descriptionRes) ->
val config = composeTestRule.onNodeWithText(glyph).fetchSemanticsNode().config
val clickLabel = if (config.contains(SemanticsActions.OnClick)) {
config[SemanticsActions.OnClick].label
} else {
null
}
val message = "toolbar button \"$glyph\" is missing its accessibility label"
assertEquals(message, string(descriptionRes), clickLabel)
}
}
/** Matches the editable field labelled [labelRes] but not the collapsed Cc/Bcc link buttons. */
private fun editableField(labelRes: Int) = composeTestRule.onNode(hasText(string(labelRes)) and hasSetTextAction())
}
@@ -0,0 +1,81 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.ui.compose.format
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.libremail.R
import org.libremail.ui.theme.LibreMailTheme
/**
* UI tests for the compose formatting toolbar's font-family picker (#72). [FontPicker] is
* presentational, so it is driven in isolation - independent of the surrounding
* [org.libremail.ui.compose.RichTextBodyField] editor - mirroring how `FontSizePickerTest`
* exercises its dropdown.
*/
@RunWith(AndroidJUnit4::class)
class FontPickerTest {
@get:Rule
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
private fun string(resId: Int) = composeTestRule.activity.getString(resId)
private fun setContent(selectedCss: String?, onSelect: (String?) -> Unit = {}) {
composeTestRule.setContent {
LibreMailTheme(darkTheme = false, dynamicColor = false) {
FontPicker(selectedCss = selectedCss, onSelect = onSelect)
}
}
}
@Test
fun noFontSelected_buttonShowsDefaultLabel() {
setContent(selectedCss = null)
composeTestRule.onNodeWithText(string(R.string.format_font_default)).assertIsDisplayed()
}
@Test
fun aFontSelected_buttonShowsItsDisplayName() {
val inter = FontRegistry.choices.first { it.name == "Inter" }
setContent(selectedCss = inter.css)
composeTestRule.onNodeWithText("Inter").assertIsDisplayed()
}
@Test
fun tappingTheButton_opensAMenuListingEveryRegistryFont() {
setContent(selectedCss = null)
composeTestRule.onNodeWithText(string(R.string.format_font_default)).performClick()
FontRegistry.choices.forEach { choice ->
composeTestRule.onNodeWithText(choice.name).assertIsDisplayed()
}
}
@Test
fun pickingAFontFromTheMenu_reportsItsCssStack() {
var picked: String? = "unset"
val lora = FontRegistry.choices.first { it.name == "Lora" }
setContent(selectedCss = null) { picked = it }
composeTestRule.onNodeWithText(string(R.string.format_font_default)).performClick()
composeTestRule.onNodeWithText("Lora").performClick()
assertEquals(lora.css, picked)
}
@Test
fun pickingDefaultFromTheMenu_clearsBySelectingNull() {
var picked: String? = "unset"
val inter = FontRegistry.choices.first { it.name == "Inter" }
// The button reads "Inter" here, so the menu's own "Default" entry is the only such match.
setContent(selectedCss = inter.css) { picked = it }
composeTestRule.onNodeWithText("Inter").performClick()
composeTestRule.onNodeWithText(string(R.string.format_font_default)).performClick()
assertEquals(null, picked)
}
}
@@ -0,0 +1,81 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.ui.compose.format
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.libremail.R
import org.libremail.ui.theme.LibreMailTheme
/**
* UI tests for the compose formatting toolbar's font-size dropdown (#73). [FontSizePicker] is
* presentational, so it is driven directly - independent of the surrounding
* [org.libremail.ui.compose.RichTextBodyField] editor - mirroring how `ContactAutocompleteRowTest`
* exercises its row composable in isolation.
*/
@RunWith(AndroidJUnit4::class)
class FontSizePickerTest {
@get:Rule
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
private fun string(resId: Int) = composeTestRule.activity.getString(resId)
private fun string(resId: Int, vararg args: Any) = composeTestRule.activity.getString(resId, *args)
private fun setContent(selectedPt: Int?, onSelect: (Int?) -> Unit = {}) {
composeTestRule.setContent {
LibreMailTheme(darkTheme = false, dynamicColor = false) {
FontSizePicker(selectedPt = selectedPt, onSelect = onSelect)
}
}
}
@Test
fun noSizeSelected_buttonShowsDefaultLabel() {
setContent(selectedPt = null)
composeTestRule.onNodeWithText(string(R.string.format_size_default)).assertIsDisplayed()
}
@Test
fun aSizeSelected_buttonShowsItsPointValue() {
setContent(selectedPt = 18)
composeTestRule.onNodeWithText(string(R.string.format_size_pt, 18)).assertIsDisplayed()
}
@Test
fun tappingTheButton_opensAMenuListingDefaultAndEveryPreset() {
setContent(selectedPt = null)
// Before the menu opens, "Default" only labels the anchor button itself - a unique match.
composeTestRule.onNodeWithText(string(R.string.format_size_default)).performClick()
FONT_SIZE_PRESETS_PT.forEach { pt ->
composeTestRule.onNodeWithText(string(R.string.format_size_pt, pt)).assertIsDisplayed()
}
}
@Test
fun pickingAPresetFromTheMenu_reportsItsPointSize() {
var picked: Int? = -1
setContent(selectedPt = null) { picked = it }
composeTestRule.onNodeWithText(string(R.string.format_size_default)).performClick()
composeTestRule.onNodeWithText(string(R.string.format_size_pt, 14)).performClick()
assertEquals(14, picked)
}
@Test
fun pickingDefaultFromTheMenu_clearsBySelectingNull() {
var picked: Int? = 12
setContent(selectedPt = 12) { picked = it }
// The button reads "12 pt" here, so the menu's own "Default" entry is the only such match.
composeTestRule.onNodeWithText(string(R.string.format_size_pt, 12)).performClick()
composeTestRule.onNodeWithText(string(R.string.format_size_default)).performClick()
assertEquals(null, picked)
}
}
@@ -0,0 +1,68 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.ui.compose.format
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.libremail.richtext.RichAlign
import org.libremail.ui.theme.LibreMailTheme
/**
* UI tests for the compose formatting toolbar's three-state paragraph-alignment control (#76).
* [ParagraphAlignmentControl] is presentational, so it is driven in isolation - independent of the
* surrounding [org.libremail.ui.compose.RichTextBodyField] editor - mirroring how `FontSizePickerTest`
* exercises its picker.
*/
@RunWith(AndroidJUnit4::class)
class ParagraphAlignmentControlTest {
@get:Rule
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
private fun setContent(selected: RichAlign?, onSelect: (RichAlign) -> Unit = {}) {
composeTestRule.setContent {
LibreMailTheme(darkTheme = false, dynamicColor = false) {
ParagraphAlignmentControl(selected = selected, onSelect = onSelect)
}
}
}
@Test
fun showsAllThreeAlignmentGlyphs() {
setContent(selected = RichAlign.START)
composeTestRule.onNodeWithText(ALIGN_START_GLYPH).assertIsDisplayed()
composeTestRule.onNodeWithText(ALIGN_CENTER_GLYPH).assertIsDisplayed()
composeTestRule.onNodeWithText(ALIGN_END_GLYPH).assertIsDisplayed()
}
@Test
fun tappingCenter_reportsCenter() {
var picked: RichAlign? = null
setContent(selected = RichAlign.START) { picked = it }
composeTestRule.onNodeWithText(ALIGN_CENTER_GLYPH).performClick()
assertEquals(RichAlign.CENTER, picked)
}
@Test
fun tappingEnd_reportsEnd() {
var picked: RichAlign? = null
setContent(selected = RichAlign.START) { picked = it }
composeTestRule.onNodeWithText(ALIGN_END_GLYPH).performClick()
assertEquals(RichAlign.END, picked)
}
@Test
fun tappingStart_reportsStart() {
var picked: RichAlign? = null
setContent(selected = RichAlign.CENTER) { picked = it }
composeTestRule.onNodeWithText(ALIGN_START_GLYPH).performClick()
assertEquals(RichAlign.START, picked)
}
}
@@ -0,0 +1,219 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.ui.mailbox
import androidx.activity.ComponentActivity
import androidx.compose.material3.ModalDrawerSheet
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.libremail.R
import org.libremail.domain.model.Account
import org.libremail.domain.model.AuthType
import org.libremail.domain.model.Folder
import org.libremail.domain.model.FolderRole
import org.libremail.domain.model.MailSecurity
import org.libremail.domain.model.ServerConfig
import org.libremail.ui.theme.LibreMailTheme
/** UI test for the navigation drawer: folder rendering, friendly names, the account switcher, and taps. */
@RunWith(AndroidJUnit4::class)
class FolderDrawerTest {
@get:Rule
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
private fun string(resId: Int) = composeTestRule.activity.getString(resId)
private val alice = account("imap:a", "alice@example.org")
private val bob = account("imap:b", "bob@example.org")
@Test
fun singleAccount_rendersStandardFoldersWithFriendlyNames() {
setContent(
accounts = listOf(alice),
drawerAccount = alice,
folders = listOf(
folder("imap:a", "INBOX", "INBOX", FolderRole.INBOX),
folder("imap:a", "[Gmail]/Sent Mail", "Sent Mail", FolderRole.SENT),
// Give ARCHIVE a server name that differs from its friendly label so the assertion
// below actually discriminates a role-to-label regression (displayName != friendly).
folder("imap:a", "[Gmail]/All Mail", "All Mail", FolderRole.ARCHIVE),
folder("imap:a", "Receipts", "Receipts", FolderRole.NORMAL),
),
)
composeTestRule.onNodeWithText(string(R.string.folder_inbox)).assertIsDisplayed()
// Standard folders use the friendly role name, not the raw server name — verified for both
// Sent ("Sent Mail" -> "Sent") and Archive ("All Mail" -> "Archive").
composeTestRule.onNodeWithText(string(R.string.folder_sent)).assertIsDisplayed()
composeTestRule.onNodeWithText("Sent Mail").assertDoesNotExist()
composeTestRule.onNodeWithText(string(R.string.folder_archive)).assertIsDisplayed()
composeTestRule.onNodeWithText("All Mail").assertDoesNotExist()
// Normal folders keep their server name.
composeTestRule.onNodeWithText("Receipts").assertIsDisplayed()
// A single account shows no account switcher / "All Inboxes" entry.
composeTestRule.onNodeWithText(string(R.string.folder_all_inboxes)).assertDoesNotExist()
}
@Test
fun duplicateFolderNames_areDisambiguatedWithProviderSuffix() {
val gmail = account("imap:g", "user@gmail.com").copy(
imap = ServerConfig("imap.gmail.com", 993, MailSecurity.SSL_TLS),
)
setContent(
accounts = listOf(gmail),
drawerAccount = gmail,
folders = listOf(
folder("imap:g", "INBOX", "INBOX", FolderRole.INBOX),
// Gmail's built-in Drafts (server special-use) alongside a same-named user folder.
folder("imap:g", "[Gmail]/Drafts", "Drafts", FolderRole.DRAFTS, specialUse = true),
folder("imap:g", "Drafts", "Drafts", FolderRole.DRAFTS),
),
)
// The provider's built-in folder is suffixed; the user folder keeps the plain name.
composeTestRule.onNodeWithText("Drafts - Gmail").assertIsDisplayed()
composeTestRule.onNodeWithText("Drafts").assertIsDisplayed()
}
@Test
fun accountSwitchGap_staleFolderListKeepsItsOwnProviderSuffix() {
val gmail = account("imap:g", "user@gmail.com").copy(
imap = ServerConfig("imap.gmail.com", 993, MailSecurity.SSL_TLS),
)
val outlook = account("imap:o", "user@outlook.com").copy(
authType = AuthType.OAUTH_OUTLOOK,
imap = ServerConfig("outlook.office365.com", 993, MailSecurity.SSL_TLS),
)
// The transient frame from issue #61: the drawer account has already switched to Outlook,
// but the folder list still holds the Gmail account's folders until its query emits.
setContent(
accounts = listOf(gmail, outlook),
drawerAccount = outlook,
folders = listOf(
folder("imap:g", "INBOX", "INBOX", FolderRole.INBOX),
folder("imap:g", "[Gmail]/Drafts", "Drafts", FolderRole.DRAFTS, specialUse = true),
folder("imap:g", "Drafts", "Drafts", FolderRole.DRAFTS),
),
)
// The de-dup suffix derives from the folders' own account — never the incoming account.
composeTestRule.onNodeWithText("Drafts - Gmail").assertIsDisplayed()
composeTestRule.onNodeWithText("Drafts - Outlook").assertDoesNotExist()
}
@Test
fun tappingAFolder_reportsItsAccountAndFullName() {
var picked: Pair<String, String>? = null
setContent(
accounts = listOf(alice),
drawerAccount = alice,
folders = listOf(
folder("imap:a", "INBOX", "INBOX", FolderRole.INBOX),
folder("imap:a", "Archive", "Archive", FolderRole.ARCHIVE),
),
onSelectFolder = { accountId, fullName -> picked = accountId to fullName },
)
composeTestRule.onNodeWithText(string(R.string.folder_archive)).performClick()
assertEquals("imap:a" to "Archive", picked)
}
@Test
fun multipleAccounts_showSwitcherAndAllInboxesEntry() {
var unifiedTapped = false
setContent(
accounts = listOf(alice, bob),
drawerAccount = alice,
folders = listOf(folder("imap:a", "INBOX", "INBOX", FolderRole.INBOX)),
onSelectUnifiedInbox = { unifiedTapped = true },
)
// The switcher shows the active drawer account, and the unified entry is available.
composeTestRule.onNodeWithText("alice@example.org").assertIsDisplayed()
composeTestRule.onNodeWithText(string(R.string.folder_all_inboxes)).assertIsDisplayed()
composeTestRule.onNodeWithText(string(R.string.folder_all_inboxes)).performClick()
assertTrue(unifiedTapped)
}
@Test
fun folderWithUnreadMail_showsCountBadge_andReadFolderShowsNone() {
setContent(
accounts = listOf(alice),
drawerAccount = alice,
folders = listOf(
folder("imap:a", "INBOX", "INBOX", FolderRole.INBOX),
folder("imap:a", "Archive", "Archive", FolderRole.ARCHIVE),
),
folderUnreadCounts = mapOf("INBOX" to 3),
)
// The inbox badge announces its exact count for screen readers.
val threeUnread = composeTestRule.activity.resources
.getQuantityString(R.plurals.folder_unread_count_description, 3, 3)
composeTestRule.onNodeWithContentDescription(threeUnread).assertIsDisplayed()
// Archive has no unread mail, so no badge is rendered for it.
val oneUnread = composeTestRule.activity.resources
.getQuantityString(R.plurals.folder_unread_count_description, 1, 1)
composeTestRule.onNodeWithContentDescription(oneUnread).assertDoesNotExist()
}
private fun setContent(
accounts: List<Account>,
drawerAccount: Account?,
folders: List<Folder>,
folderUnreadCounts: Map<String, Int> = emptyMap(),
accountsWithUnread: Set<String> = emptySet(),
selectedAccountId: String? = null,
selectedFolder: String = "INBOX",
onSelectUnifiedInbox: () -> Unit = {},
onSelectFolder: (String, String) -> Unit = { _, _ -> },
onSelectDrawerAccount: (String) -> Unit = {},
) {
composeTestRule.setContent {
LibreMailTheme(darkTheme = false, dynamicColor = false) {
ModalDrawerSheet {
FolderDrawer(
accounts = accounts,
drawerAccount = drawerAccount,
folders = folders,
folderUnreadCounts = folderUnreadCounts,
accountsWithUnread = accountsWithUnread,
selectedAccountId = selectedAccountId,
selectedFolder = selectedFolder,
onSelectUnifiedInbox = onSelectUnifiedInbox,
onSelectFolder = onSelectFolder,
onSelectDrawerAccount = onSelectDrawerAccount,
)
}
}
}
}
private fun account(id: String, email: String) = Account(
id = id,
email = email,
displayName = email,
authType = AuthType.PASSWORD_IMAP,
imap = ServerConfig("imap.example.org", 993, MailSecurity.SSL_TLS),
smtp = ServerConfig("smtp.example.org", 465, MailSecurity.SSL_TLS),
)
private fun folder(
accountId: String,
fullName: String,
displayName: String,
role: FolderRole,
specialUse: Boolean = false,
) = Folder(accountId, fullName, displayName, role, selectable = true, specialUse = specialUse)
}
@@ -0,0 +1,287 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.ui.mailbox
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.hasAnyAncestor
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.isDialog
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.longClick
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTouchInput
import androidx.lifecycle.SavedStateHandle
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.libremail.R
import org.libremail.domain.model.Account
import org.libremail.domain.model.AuthType
import org.libremail.domain.model.Folder
import org.libremail.domain.model.FolderRole
import org.libremail.domain.model.MailSecurity
import org.libremail.domain.model.Message
import org.libremail.domain.model.ServerConfig
import org.libremail.ui.FakeAccountRepository
import org.libremail.ui.FakeMailRepository
import org.libremail.ui.FakeMailSyncer
import org.libremail.ui.theme.LibreMailTheme
/**
* End-to-end UI test for the mailbox's long-press contextual action bar. Drives the real
* [MailboxScreen] + [MailboxViewModel] backed by in-memory fakes.
*/
@RunWith(AndroidJUnit4::class)
class MailboxScreenTest {
@get:Rule
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
private fun string(resId: Int) = composeTestRule.activity.getString(resId)
private fun count(n: Int) = composeTestRule.activity.getString(R.string.cab_selected_count, n)
private val account = Account(
id = "imap:a",
email = "a@example.org",
displayName = "A",
authType = AuthType.PASSWORD_IMAP,
imap = ServerConfig("imap.example.org", 993, MailSecurity.SSL_TLS),
smtp = ServerConfig("smtp.example.org", 465, MailSecurity.SSL_TLS),
)
private val gmailAccount = account.copy(
email = "a@gmail.com",
imap = ServerConfig("imap.gmail.com", 993, MailSecurity.SSL_TLS),
)
/** A Gmail-style tree where the built-in Drafts collides with a same-named user folder. */
private val duplicateDraftsFolders = listOf(
Folder("imap:a", "INBOX", "INBOX", FolderRole.INBOX, selectable = true),
Folder("imap:a", "[Gmail]/Drafts", "Drafts", FolderRole.DRAFTS, selectable = true, specialUse = true),
Folder("imap:a", "Drafts", "Drafts", FolderRole.DRAFTS, selectable = true),
)
private fun message(uid: String, subject: String, bodyFetched: Boolean = false, folder: String = "INBOX") = Message(
id = "imap:a:$folder:$uid",
accountId = "imap:a",
sender = "Sender $uid",
senderEmail = "s$uid@example.org",
subject = subject,
snippet = "",
body = "",
isHtml = false,
timestampMillis = 1_000L,
isRead = true,
isStarred = false,
folder = folder,
inInbox = true,
bodyFetched = bodyFetched,
)
private fun setContent(repo: FakeMailRepository, activeAccount: Account = account): MailboxViewModel {
val viewModel = MailboxViewModel(
repo,
FakeAccountRepository(accounts = listOf(activeAccount)),
FakeMailSyncer(),
SavedStateHandle(),
)
composeTestRule.setContent {
LibreMailTheme(darkTheme = false, dynamicColor = false) {
MailboxScreen(
onOpenMessage = {},
onCompose = {},
onOpenDrafts = {},
onOpenOutbox = {},
onAddAccount = {},
onOpenCompose = {},
onSelectTab = {},
viewModel = viewModel,
)
}
}
return viewModel
}
private fun waitForText(text: String) = composeTestRule.waitUntil(5_000) {
composeTestRule.onAllNodesWithText(text).fetchSemanticsNodes().isNotEmpty()
}
@Test
fun longPress_entersSelection_andCountTracksTaps() {
setContent(FakeMailRepository(messages = listOf(message("1", "First"), message("2", "Second"))))
waitForText("First")
composeTestRule.onNodeWithText("First").performTouchInput { longClick() }
composeTestRule.onNodeWithText(count(1)).assertIsDisplayed()
composeTestRule.onNodeWithText("Second").performClick()
composeTestRule.onNodeWithText(count(2)).assertIsDisplayed()
}
@Test
fun overflow_showsReplyActions_forSingleSelection() {
setContent(FakeMailRepository(messages = listOf(message("1", "First"))))
waitForText("First")
composeTestRule.onNodeWithText("First").performTouchInput { longClick() }
composeTestRule.onNodeWithContentDescription(string(R.string.action_more)).performClick()
composeTestRule.onNodeWithText(string(R.string.action_reply)).assertIsDisplayed()
composeTestRule.onNodeWithText(string(R.string.action_reply_all)).assertIsDisplayed()
composeTestRule.onNodeWithText(string(R.string.action_forward)).assertIsDisplayed()
}
@Test
fun overflow_hidesReplyActions_forMultiSelection() {
setContent(FakeMailRepository(messages = listOf(message("1", "First"), message("2", "Second"))))
waitForText("First")
composeTestRule.onNodeWithText("First").performTouchInput { longClick() }
composeTestRule.onNodeWithText("Second").performClick()
composeTestRule.onNodeWithContentDescription(string(R.string.action_more)).performClick()
composeTestRule.onNodeWithText(string(R.string.action_select_all)).assertIsDisplayed()
composeTestRule.onNodeWithText(string(R.string.action_reply)).assertDoesNotExist()
composeTestRule.onNodeWithText(string(R.string.action_forward)).assertDoesNotExist()
}
@Test
fun archiveIcon_isDirect_andArchivesTheSelection() {
val repo = FakeMailRepository(messages = listOf(message("1", "First"), message("2", "Second")))
setContent(repo)
waitForText("First")
composeTestRule.onNodeWithText("First").performTouchInput { longClick() }
composeTestRule.onNodeWithText("Second").performClick()
// A direct icon button — no trip through the overflow menu.
composeTestRule.onNodeWithContentDescription(string(R.string.action_archive)).performClick()
composeTestRule.waitUntil(5_000) { repo.archivedIds.isNotEmpty() }
assertEquals(setOf("imap:a:INBOX:1", "imap:a:INBOX:2"), repo.archivedIds.first().toSet())
}
@Test
fun spamIcon_isDirect_andConfirmsBeforeReporting() {
val repo = FakeMailRepository(messages = listOf(message("1", "First")))
setContent(repo)
waitForText("First")
composeTestRule.onNodeWithText("First").performTouchInput { longClick() }
composeTestRule.onNodeWithContentDescription(string(R.string.action_spam)).performClick()
composeTestRule.onNodeWithText(string(R.string.confirm_spam_title)).assertIsDisplayed()
composeTestRule.onNodeWithText(string(R.string.action_move)).performClick()
composeTestRule.waitUntil(5_000) { repo.spammedIds.isNotEmpty() }
assertEquals(listOf("imap:a:INBOX:1"), repo.spammedIds.first())
}
@Test
fun archiveIcon_hides_whileViewingTheArchiveFolder() {
val repo = FakeMailRepository(
messages = listOf(message("1", "Old news", folder = "Archive")),
folders = listOf(Folder("imap:a", "Archive", "Archive", FolderRole.ARCHIVE, selectable = true)),
)
val viewModel = setContent(repo)
viewModel.selectFolder("imap:a", "Archive")
waitForText("Old news")
composeTestRule.onNodeWithText("Old news").performTouchInput { longClick() }
composeTestRule.onNodeWithContentDescription(string(R.string.action_archive)).assertDoesNotExist()
composeTestRule.onNodeWithContentDescription(string(R.string.action_spam)).assertIsDisplayed()
composeTestRule.onNodeWithContentDescription(string(R.string.action_delete)).assertIsDisplayed()
}
@Test
fun delete_confirmsMoveToTrash_thenTrashesViaRepository() {
val repo = FakeMailRepository(messages = listOf(message("1", "First")))
setContent(repo)
waitForText("First")
composeTestRule.onNodeWithText("First").performTouchInput { longClick() }
composeTestRule.onNodeWithContentDescription(string(R.string.action_delete)).performClick()
composeTestRule.onNodeWithText(string(R.string.confirm_trash_title)).assertIsDisplayed()
composeTestRule.onNodeWithText(string(R.string.action_move)).performClick()
composeTestRule.waitUntil(5_000) { repo.trashedIds.isNotEmpty() }
assertEquals(listOf("imap:a:INBOX:1"), repo.trashedIds.first())
}
@Test
fun move_picker_movesSelectionToTheChosenFolder() {
val repo = FakeMailRepository(
messages = listOf(message("1", "First")),
folders = listOf(
Folder("imap:a", "INBOX", "INBOX", FolderRole.INBOX, selectable = true),
Folder("imap:a", "Receipts", "Receipts", FolderRole.NORMAL, selectable = true),
),
)
setContent(repo)
waitForText("First")
composeTestRule.onNodeWithText("First").performTouchInput { longClick() }
composeTestRule.onNodeWithContentDescription(string(R.string.action_more)).performClick()
composeTestRule.onNodeWithText(string(R.string.action_move)).performClick()
// The off-screen navigation drawer also lists "Receipts", so scope the tap to the move dialog.
composeTestRule.onNode(hasText("Receipts") and hasAnyAncestor(isDialog())).performClick()
composeTestRule.waitUntil(5_000) { repo.movedToFolder.isNotEmpty() }
assertEquals("Receipts", repo.movedToFolder.first().second)
}
@Test
fun movePicker_disambiguatesDuplicateNames_andMovesToTheChosenFolder() {
val repo = FakeMailRepository(
messages = listOf(message("1", "First")),
folders = duplicateDraftsFolders,
)
setContent(repo, activeAccount = gmailAccount)
waitForText("First")
composeTestRule.onNodeWithText("First").performTouchInput { longClick() }
composeTestRule.onNodeWithContentDescription(string(R.string.action_more)).performClick()
composeTestRule.onNodeWithText(string(R.string.action_move)).performClick()
// Issue #59: the two same-named Drafts folders are told apart in the picker, and choosing
// the suffixed row files into the provider's built-in folder — not the user folder.
composeTestRule.onNode(hasText("Drafts") and hasAnyAncestor(isDialog())).assertIsDisplayed()
composeTestRule.onNode(hasText("Drafts - Gmail") and hasAnyAncestor(isDialog())).performClick()
composeTestRule.waitUntil(5_000) { repo.movedToFolder.isNotEmpty() }
assertEquals("[Gmail]/Drafts", repo.movedToFolder.first().second)
}
@Test
fun appBarTitle_keepsTheDisambiguatedFolderLabel() {
val repo = FakeMailRepository(
messages = listOf(message("1", "First")),
folders = duplicateDraftsFolders,
)
setContent(repo, activeAccount = gmailAccount)
waitForText("First")
composeTestRule.onNodeWithContentDescription(string(R.string.drawer_open)).performClick()
composeTestRule.onNodeWithText("Drafts - Gmail").assertIsDisplayed()
composeTestRule.onNodeWithText("Drafts - Gmail").performClick()
// Issue #59: the app-bar title keeps the drawer's de-duplicated label instead of collapsing
// to an ambiguous "Drafts". Once selected, the label renders twice — the app-bar title plus
// the (composed but closed) drawer's entry.
composeTestRule.waitUntil(5_000) {
composeTestRule.onAllNodesWithText("Drafts - Gmail").fetchSemanticsNodes().size == 2
}
}
@Test
fun offlineIndicator_showsForCachedMessages() {
setContent(FakeMailRepository(messages = listOf(message("1", "Cached", bodyFetched = true))))
waitForText("Cached")
composeTestRule.onNodeWithContentDescription(string(R.string.message_available_offline)).assertIsDisplayed()
}
}
@@ -0,0 +1,191 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.ui.onboarding
import android.app.Activity
import android.app.Instrumentation
import android.net.Uri
import android.provider.Settings
import androidx.activity.ComponentActivity
import androidx.compose.material3.Text
import androidx.compose.runtime.getValue
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import androidx.test.espresso.intent.Intents
import androidx.test.espresso.intent.matcher.IntentMatchers.hasAction
import androidx.test.espresso.intent.matcher.IntentMatchers.hasData
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import kotlinx.coroutines.runBlocking
import org.hamcrest.CoreMatchers.allOf
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.libremail.R
import org.libremail.contacts.ContactsPermissionManager
import org.libremail.data.settings.SettingsRepository
import org.libremail.push.BatteryOptimizationManager
import org.libremail.ui.navigation.Routes
import org.libremail.ui.theme.LibreMailTheme
/**
* End-to-end test for the onboarding battery opt-in step (#49). It drives the real
* [BatteryOptimizationScreen] + graph-scoped [OnboardingViewModel] through a NavHost that mirrors the
* production "add another? → (optional) battery → inbox" tail (see
* `LibreMailApp.onboardingFinishDestinations`).
*
* Battery status comes from the real [BatteryOptimizationManager]: a fresh emulator is never on the
* battery allowlist, so the step is offered. The "already unrestricted" skip can't be forced from a
* test (there's no API to set it) and is covered by the view-model unit tests; the "already handled"
* skip is exercised here through the real settings DataStore.
*/
@RunWith(AndroidJUnit4::class)
class BatteryOptimizationStepTest {
@get:Rule
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
private lateinit var settingsRepository: SettingsRepository
private lateinit var onboarding: OnboardingViewModel
private fun string(resId: Int) = composeTestRule.activity.getString(resId)
private fun waitForText(text: String) = composeTestRule.waitUntil(10_000) {
composeTestRule.onAllNodesWithText(text).fetchSemanticsNodes().isNotEmpty()
}
/**
* Renders the "add another? → battery → inbox" tail with one account already added this session,
* starting on the add-another prompt. [handled] seeds the persisted "prompt handled" flag so the
* skip path can be exercised through the real repository.
*/
private fun setContent(handled: Boolean) {
val context = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext
settingsRepository = SettingsRepository(context)
runBlocking { settingsRepository.setBatteryPromptHandled(handled) }
onboarding = OnboardingViewModel(
BatteryOptimizationManager(context),
ContactsPermissionManager(context),
settingsRepository,
)
onboarding.onAccountAdded(FIRST_ACCOUNT_ID)
composeTestRule.setContent {
LibreMailTheme(darkTheme = false, dynamicColor = false) {
val navController = rememberNavController()
NavHost(navController = navController, startDestination = Routes.ONBOARDING_ADD_ANOTHER) {
composable(Routes.ONBOARDING_ADD_ANOTHER) {
val batteryPromptNeeded by onboarding.batteryPromptNeeded.collectAsStateWithLifecycle()
AddAnotherAccountScreen(
onAddAnother = {},
onFinish = {
if (batteryPromptNeeded == true) {
navController.navigate(Routes.ONBOARDING_BATTERY)
} else {
navController.navigate(Routes.mailboxForAccount(FIRST_ACCOUNT_ID)) {
popUpTo(Routes.ONBOARDING_ADD_ANOTHER) { inclusive = true }
}
}
},
)
}
composable(Routes.ONBOARDING_BATTERY) {
BatteryOptimizationScreen(
viewModel = onboarding,
onFinish = {
onboarding.markBatteryPromptHandled()
navController.navigate(Routes.mailboxForAccount(FIRST_ACCOUNT_ID)) {
popUpTo(Routes.ONBOARDING_ADD_ANOTHER) { inclusive = true }
}
},
)
}
composable(
route = Routes.MAILBOX_PATTERN,
arguments = listOf(
navArgument(Routes.MAILBOX_ARG_ACCOUNT) {
type = NavType.StringType
defaultValue = ""
},
),
) {
Text(INBOX_MARKER)
}
}
}
}
}
@Test
fun batteryStep_isOffered_thenNotNow_landsOnInbox() {
setContent(handled = false)
// The decision resolves asynchronously (a DataStore read); wait before driving the finish tap.
composeTestRule.waitUntil(10_000) { onboarding.batteryPromptNeeded.value == true }
composeTestRule.onNodeWithText(string(R.string.onboarding_add_another_no)).performClick()
// The battery opt-in step is shown...
waitForText(string(R.string.onboarding_battery_title))
composeTestRule.onNodeWithText(string(R.string.onboarding_battery_title)).assertIsDisplayed()
// ...and "Not now" continues to the inbox and records the prompt as handled (so it won't nag).
composeTestRule.onNodeWithText(string(R.string.onboarding_battery_not_now)).performClick()
waitForText(INBOX_MARKER)
composeTestRule.onNodeWithText(INBOX_MARKER).assertIsDisplayed()
composeTestRule.waitUntil(5_000) { runBlocking { settingsRepository.isBatteryPromptHandled() } }
}
@Test
fun batteryStep_takeMeThere_opensThisAppsSystemSettings() {
setContent(handled = false)
composeTestRule.waitUntil(10_000) { onboarding.batteryPromptNeeded.value == true }
composeTestRule.onNodeWithText(string(R.string.onboarding_add_another_no)).performClick()
waitForText(string(R.string.onboarding_battery_title))
val packageName = InstrumentationRegistry.getInstrumentation().targetContext.packageName
Intents.init()
try {
// Stub the match so the real system settings screen never actually launches mid-test.
Intents.intending(hasAction(Settings.ACTION_APPLICATION_DETAILS_SETTINGS))
.respondWith(Instrumentation.ActivityResult(Activity.RESULT_OK, null))
composeTestRule.onNodeWithText(string(R.string.onboarding_battery_take_me)).performClick()
// Deep-links to *this app's* details screen (where Battery → Unrestricted lives).
Intents.intended(
allOf(
hasAction(Settings.ACTION_APPLICATION_DETAILS_SETTINGS),
hasData(Uri.fromParts("package", packageName, null)),
),
)
} finally {
Intents.release()
}
}
@Test
fun finish_skipsBatteryStep_whenAlreadyHandled() {
setContent(handled = true)
composeTestRule.waitUntil(10_000) { onboarding.batteryPromptNeeded.value == false }
composeTestRule.onNodeWithText(string(R.string.onboarding_add_another_no)).performClick()
// Straight to the inbox — the opt-in step is skipped entirely.
waitForText(INBOX_MARKER)
composeTestRule.onNodeWithText(INBOX_MARKER).assertIsDisplayed()
composeTestRule.onNodeWithText(string(R.string.onboarding_battery_title)).assertDoesNotExist()
}
private companion object {
const val INBOX_MARKER = "INBOX-REACHED"
const val FIRST_ACCOUNT_ID = "imap:e2e@example.com"
}
}
@@ -0,0 +1,97 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.ui.onboarding
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.libremail.R
import org.libremail.ui.theme.LibreMailTheme
/**
* UI tests for the onboarding contacts-access step (#127, #128). They drive the presentational
* [ContactsAccessContent] with explicit signals so the three paths — skip, grant (the "done" state),
* and request (with the re-ask rationale) — run deterministically without a live system permission
* dialog (whose grant state would otherwise leak across the shared instrumentation process).
*/
@RunWith(AndroidJUnit4::class)
class ContactsAccessStepTest {
@get:Rule
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
private fun string(resId: Int) = composeTestRule.activity.getString(resId)
private fun setContent(
granted: Boolean,
showRationale: Boolean = false,
onAllow: () -> Unit = {},
onSkip: () -> Unit = {},
onContinue: () -> Unit = {},
) {
composeTestRule.setContent {
LibreMailTheme(darkTheme = false, dynamicColor = false) {
ContactsAccessContent(
granted = granted,
showRationale = showRationale,
onAllow = onAllow,
onSkip = onSkip,
onContinue = onContinue,
)
}
}
}
@Test
fun notGranted_notNow_skipsTheStep() {
var skipped = false
var allowed = false
setContent(granted = false, onAllow = { allowed = true }, onSkip = { skipped = true })
composeTestRule.onNodeWithText(string(R.string.onboarding_contacts_title)).assertIsDisplayed()
composeTestRule.onNodeWithText(string(R.string.onboarding_contacts_not_now)).performClick()
assertTrue("Not now must invoke the skip callback", skipped)
assertFalse("Skipping must not request the permission", allowed)
}
@Test
fun notGranted_allow_triggersTheRequest() {
var allowed = false
setContent(granted = false, onAllow = { allowed = true })
composeTestRule.onNodeWithText(string(R.string.onboarding_contacts_allow)).performClick()
assertTrue("Allow must trigger the permission request", allowed)
}
@Test
fun granted_showsDoneState_andContinues() {
var continued = false
setContent(granted = true, onContinue = { continued = true })
// The "done" copy is shown and the request/skip buttons are gone.
composeTestRule.onNodeWithText(string(R.string.onboarding_contacts_done_title)).assertIsDisplayed()
composeTestRule.onNodeWithText(string(R.string.onboarding_contacts_allow)).assertDoesNotExist()
composeTestRule.onNodeWithText(string(R.string.onboarding_contacts_not_now)).assertDoesNotExist()
composeTestRule.onNodeWithText(string(R.string.onboarding_contacts_continue)).performClick()
assertTrue("Continue must invoke the continue callback", continued)
}
@Test
fun reRequest_showsRationale() {
setContent(granted = false, showRationale = true)
// A re-request explains itself (shouldShowRequestPermissionRationale handling, #128).
composeTestRule.onNodeWithText(string(R.string.onboarding_contacts_rationale)).assertIsDisplayed()
composeTestRule.onNodeWithText(string(R.string.onboarding_contacts_allow)).assertIsDisplayed()
}
}
@@ -0,0 +1,92 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.ui.onboarding
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import androidx.test.espresso.Espresso
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.libremail.R
import org.libremail.ui.theme.LibreMailTheme
/**
* Component test for [LicenseScreen] (#172), in isolation from the real onboarding nav graph (that
* wiring — persisting acceptance, popping the license off the back stack, calling `finish()` on
* Decline — belongs to `onboardingGraph()` in `LibreMailApp.kt`, not this composable). This test only
* owns the screen's own contract: Agree is gated on having scrolled to the end, Decline (and back)
* always works, and the real bundled GPL-3.0 text actually renders.
*/
@RunWith(AndroidJUnit4::class)
class LicenseScreenTest {
@get:Rule
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
private fun string(resId: Int) = composeTestRule.activity.getString(resId)
private fun setContent(onAgree: () -> Unit = {}, onDecline: () -> Unit = {}) {
composeTestRule.setContent {
LibreMailTheme(darkTheme = false, dynamicColor = false) {
LicenseScreen(onAgree = onAgree, onDecline = onDecline)
}
}
}
@Test
fun licenseText_rendersTheRealBundledGplText() {
setContent()
// Proves res/raw/license.txt actually loaded, not just that some placeholder text exists —
// an empty or missing resource would make agreeButton_isDisabledUntilScrolledToTheEnd below
// pass for the wrong reason (nothing to scroll).
composeTestRule.onNodeWithText("GNU GENERAL PUBLIC LICENSE", substring = true).assertExists()
}
@Test
fun agreeButton_isDisabledUntilScrolledToTheEnd() {
setContent()
composeTestRule.onNodeWithText(string(R.string.license_agree)).assertIsNotEnabled()
}
@Test
fun agreeButton_scrolledToEnd_becomesEnabledAndInvokesOnAgree() {
var agreed = false
setContent(onAgree = { agreed = true })
composeTestRule.onNodeWithTag(LICENSE_SCROLL_END_TAG).performScrollTo()
composeTestRule.onNodeWithText(string(R.string.license_agree)).assertIsEnabled().performClick()
assertTrue(agreed)
}
@Test
fun declineButton_worksWithoutScrolling_andInvokesOnDecline() {
var declined = false
setContent(onDecline = { declined = true })
// No scrolling first: Decline must never be gated the way Agree is.
composeTestRule.onNodeWithText(string(R.string.license_decline)).assertIsEnabled().performClick()
assertTrue(declined)
}
@Test
fun systemBack_invokesOnDeclineJustLikeTheButton() {
var declined = false
setContent(onDecline = { declined = true })
Espresso.pressBack()
assertTrue(declined)
}
}
@@ -0,0 +1,306 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.ui.onboarding
import android.Manifest
import android.os.Build
import androidx.activity.ComponentActivity
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import androidx.compose.ui.test.performTextInput
import androidx.lifecycle.SavedStateHandle
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.rule.GrantPermissionRule
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.libremail.R
import org.libremail.auth.OutlookAuthManager
import org.libremail.contacts.ContactsPermissionManager
import org.libremail.data.settings.SettingsRepository
import org.libremail.domain.model.Message
import org.libremail.push.BatteryOptimizationManager
import org.libremail.ui.FakeAccountRepository
import org.libremail.ui.FakeMailRepository
import org.libremail.ui.FakeMailSyncer
import org.libremail.ui.accountsetup.AccountPickerScreen
import org.libremail.ui.accountsetup.AccountSetupViewModel
import org.libremail.ui.accountsetup.AppPasswordSetupScreen
import org.libremail.ui.accountsetup.AppPasswordViewModel
import org.libremail.ui.mailbox.MailboxScreen
import org.libremail.ui.mailbox.MailboxViewModel
import org.libremail.ui.navigation.Routes
import org.libremail.ui.theme.LibreMailTheme
/**
* End-to-end test of the onboarding flow: a fresh install (no accounts) walks welcome → vendor
* picker → app-password setup → "add another?" → the first account's inbox.
*
* It drives the real onboarding screens + ViewModels through a real [NavHost]. The account backend is
* the in-memory [FakeAccountRepository] (a successful add makes the account observable) rather than a
* live server — GreenMail-backed connection behaviour is covered by the repository unit tests; this
* test owns the cross-screen navigation contract.
*/
@RunWith(AndroidJUnit4::class)
class OnboardingFlowTest {
@get:Rule
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
// OnboardingWelcomeScreen requests POST_NOTIFICATIONS when it first composes (#151). On API 33+
// that runtime dialog would pop over the test, backgrounding the activity and leaving the compose
// rule with "No compose hierarchies found". Pre-grant it so the flow runs uninterrupted; the
// permission only exists on API 33+, so below TIRAMISU grant nothing (granting a nonexistent
// permission errors on older devices).
@get:Rule
val notificationPermission: GrantPermissionRule =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
GrantPermissionRule.grant(Manifest.permission.POST_NOTIFICATIONS)
} else {
GrantPermissionRule.grant()
}
private fun string(resId: Int, vararg args: Any) = composeTestRule.activity.getString(resId, *args)
// Generous cap for the slow, animation-disabled CI matrix emulators; waitUntil returns as soon
// as the text appears, so the happy path is unaffected.
private fun waitForText(text: String) = composeTestRule.waitUntil(15_000) {
composeTestRule.onAllNodesWithText(text).fetchSemanticsNodes().isNotEmpty()
}
private fun inboxMessage() = Message(
id = "imap:e2e@gmail.com:INBOX:1",
accountId = "imap:e2e@gmail.com",
sender = "Welcome",
senderEmail = "welcome@gmail.com",
subject = "E2E first message",
snippet = "",
body = "",
isHtml = false,
timestampMillis = 1_000L,
isRead = false,
isStarred = false,
folder = "INBOX",
inInbox = true,
bodyFetched = false,
)
private fun setOnboardingContent(accountRepo: FakeAccountRepository, mailRepo: FakeMailRepository) {
// Real collaborators are cheap here: the manager just wraps PowerManager and the repository
// reads the on-device settings DataStore. This test drives its own nav graph (without the
// battery step), so the onboarding view model's battery decision is inert for this flow.
//
// This hand-rolled graph starts at ONBOARDING_WELCOME directly, deliberately bypassing the
// GPL-3.0 license gate (#172) that precedes it in the real onboardingGraph() in
// LibreMailApp.kt: this test owns the picker → setup → finish tail, not the license screen's
// own contract (scroll-to-agree, decline-exits, hard-gating), which LicenseScreenTest covers
// in isolation instead.
val appContext = composeTestRule.activity.applicationContext
val onboarding = OnboardingViewModel(
BatteryOptimizationManager(appContext),
ContactsPermissionManager(appContext),
SettingsRepository(appContext),
)
composeTestRule.setContent {
LibreMailTheme(darkTheme = false, dynamicColor = false) {
val navController = rememberNavController()
val context = LocalContext.current
val outlookAuthManager = remember { OutlookAuthManager(context) }
NavHost(navController = navController, startDestination = Routes.ONBOARDING_WELCOME) {
composable(Routes.ONBOARDING_WELCOME) {
OnboardingWelcomeScreen(
onAddAccount = { navController.navigate(Routes.ONBOARDING_PICKER) },
)
}
composable(Routes.ONBOARDING_PICKER) {
val viewModel = remember { AccountSetupViewModel(outlookAuthManager, accountRepo) }
AccountPickerScreen(
onBack = {},
onAccountAdded = { id ->
onboarding.onAccountAdded(id)
navController.navigate(Routes.ONBOARDING_ADD_ANOTHER)
},
onPickProvider = { provider ->
navController.navigate(Routes.onboardingAppPassword(provider.key))
},
onManualSetup = {},
viewModel = viewModel,
)
}
composable(
route = Routes.ONBOARDING_APP_PASSWORD_PATTERN,
arguments = listOf(
navArgument(Routes.APP_PASSWORD_ARG_PROVIDER) { type = NavType.StringType },
),
) { entry ->
val key = entry.arguments?.getString(Routes.APP_PASSWORD_ARG_PROVIDER).orEmpty()
val viewModel = remember {
AppPasswordViewModel(
SavedStateHandle(mapOf(Routes.APP_PASSWORD_ARG_PROVIDER to key)),
accountRepo,
)
}
AppPasswordSetupScreen(
onBack = {},
onAccountAdded = { id ->
onboarding.onAccountAdded(id)
navController.navigate(Routes.ONBOARDING_ADD_ANOTHER) {
popUpTo(Routes.ONBOARDING_PICKER)
}
},
viewModel = viewModel,
)
}
composable(Routes.ONBOARDING_ADD_ANOTHER) {
AddAnotherAccountScreen(
onAddAnother = {
navController.navigate(Routes.ONBOARDING_PICKER) {
popUpTo(Routes.ONBOARDING_PICKER) { inclusive = true }
}
},
onFinish = {
val id = onboarding.firstAddedAccountId
val dest = if (id != null) Routes.mailboxForAccount(id) else Routes.MAILBOX
navController.navigate(dest) {
popUpTo(Routes.ONBOARDING_WELCOME) { inclusive = true }
}
},
)
}
composable(
route = Routes.MAILBOX_PATTERN,
arguments = listOf(
navArgument(Routes.MAILBOX_ARG_ACCOUNT) {
type = NavType.StringType
defaultValue = ""
},
),
) { entry ->
val account = entry.arguments?.getString(Routes.MAILBOX_ARG_ACCOUNT).orEmpty()
val viewModel = remember {
MailboxViewModel(
mailRepo,
accountRepo,
FakeMailSyncer(),
SavedStateHandle(mapOf(Routes.MAILBOX_ARG_ACCOUNT to account)),
)
}
MailboxScreen(
onOpenMessage = {},
onCompose = {},
onOpenDrafts = {},
onOpenOutbox = {},
onAddAccount = {},
onOpenCompose = {},
onSelectTab = {},
viewModel = viewModel,
)
}
}
}
}
}
@Test
fun onboarding_addsAppPasswordAccount_thenLandsOnFirstAccountInbox() {
val accountRepo = FakeAccountRepository()
val mailRepo = FakeMailRepository(messages = listOf(inboxMessage()))
setOnboardingContent(accountRepo, mailRepo)
// Welcome → picker.
composeTestRule.onNodeWithText(string(R.string.onboarding_welcome_title)).assertIsDisplayed()
composeTestRule.onNodeWithText(string(R.string.onboarding_add_account)).performClick()
// Picker → Gmail app-password setup.
waitForText("Gmail")
composeTestRule.onNodeWithText("Gmail").performClick()
// App-password setup: email + app password come from the user; servers come from the preset.
// performScrollTo first — on the short default matrix emulator the fields and the "Test and
// add" button sit below the fold of this scrolling screen, and a positional click on an
// off-screen button is a silent no-op (which is why this passed only on API 37's taller AVD).
waitForText(string(R.string.app_password_email))
// Gmail requires 2-Step Verification before app passwords, so its screen links Google's
// setup article (issue #98). iCloud gets the same kind of link, in Apple's own terminology
// (see icloudSetup_hasTwoFactorHelpLink); Yahoo does not (see
// yahooSetup_hasNoTwoFactorHelpLink).
composeTestRule.onNodeWithText(string(R.string.app_password_2fa_help))
.performScrollTo().assertIsDisplayed()
composeTestRule.onNodeWithText(string(R.string.app_password_email))
.performScrollTo().performTextInput("e2e@gmail.com")
composeTestRule.onNodeWithText(string(R.string.app_password_field))
.performScrollTo().performTextInput("app-pass")
composeTestRule.onNodeWithText(string(R.string.app_password_test_and_add))
.performScrollTo().performClick()
// "Add another?" prompt → No.
waitForText(string(R.string.onboarding_add_another_prompt))
composeTestRule.onNodeWithText(string(R.string.onboarding_add_another_no)).performClick()
// Landed on the first (and only) account's inbox.
waitForText("E2E first message")
composeTestRule.onNodeWithText("E2E first message").assertIsDisplayed()
}
@Test
fun yahooSetup_hasNoTwoFactorHelpLink() {
setOnboardingContent(FakeAccountRepository(), FakeMailRepository())
composeTestRule.onNodeWithText(string(R.string.onboarding_add_account)).performClick()
waitForText("Yahoo Mail")
composeTestRule.onNodeWithText("Yahoo Mail").performClick()
// Yahoo's setup screen keeps its app-password link (now pointing at Yahoo's step-by-step
// instructions article instead of the generic account-security page, issue #155)…
waitForText(string(R.string.app_password_open_page, "Yahoo Mail"))
// …but gains no two-factor help link: unlike Gmail and iCloud, Yahoo gates nothing on it
// (issue #98, #153, #155).
composeTestRule.onNodeWithText(string(R.string.app_password_2fa_help)).assertDoesNotExist()
composeTestRule.onNodeWithText(string(R.string.app_password_2fa_help_icloud)).assertDoesNotExist()
}
@Test
fun icloudSetup_hasTwoFactorHelpLink() {
setOnboardingContent(FakeAccountRepository(), FakeMailRepository())
composeTestRule.onNodeWithText(string(R.string.onboarding_add_account)).performClick()
waitForText("iCloud Mail")
composeTestRule.onNodeWithText("iCloud Mail").performClick()
// Apple also won't issue an app-specific password until two-factor authentication is on,
// so iCloud's screen links Apple's own setup article too — using Apple's terminology for
// the button ("Two-Factor Authentication"), not Google's "2-Step Verification" (issue #153).
waitForText(string(R.string.app_password_2fa_help_icloud))
composeTestRule.onNodeWithText(string(R.string.app_password_2fa_help_icloud))
.performScrollTo().assertIsDisplayed()
composeTestRule.onNodeWithText(string(R.string.app_password_open_page, "iCloud Mail"))
.assertIsDisplayed()
}
@Test
fun appPasswordSetup_showsAccountPasswordDisclaimerNearField() {
setOnboardingContent(FakeAccountRepository(), FakeMailRepository())
composeTestRule.onNodeWithText(string(R.string.onboarding_add_account)).performClick()
waitForText("Gmail")
composeTestRule.onNodeWithText("Gmail").performClick()
// Issue #160: new users unfamiliar with app passwords commonly try their regular account
// password first. The disclaimer must render as supporting text directly under the "App
// password" field itself (not only in the intro InfoCards above), on every preset provider
// screen since they all share this composable.
waitForText(string(R.string.app_password_field))
composeTestRule.onNodeWithText(string(R.string.app_password_field_disclaimer))
.performScrollTo().assertIsDisplayed()
}
}
@@ -0,0 +1,114 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.ui.reader
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.lifecycle.SavedStateHandle
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.libremail.R
import org.libremail.data.settings.SettingsRepository
import org.libremail.domain.model.Attachment
import org.libremail.domain.model.Message
import org.libremail.ui.FakeMailRepository
import org.libremail.ui.navigation.Routes
import org.libremail.ui.theme.LibreMailTheme
/** Compose UI tests for the reader's attachment list: the downloaded indicator and the accordion. */
@RunWith(AndroidJUnit4::class)
class ReaderScreenTest {
@get:Rule
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
private fun string(resId: Int) = composeTestRule.activity.getString(resId)
private fun seeMore(extraCount: Int) =
composeTestRule.activity.resources.getQuantityString(R.plurals.attachments_see_more, extraCount, extraCount)
private val messageId = "imap:a:INBOX:1"
private val message = Message(
id = messageId, accountId = "imap:a", sender = "Sender", senderEmail = "s@example.org",
subject = "Subject", snippet = "", body = "Hello body", isHtml = false, timestampMillis = 1_000L,
isRead = true, isStarred = false,
)
private fun attachment(partIndex: Int, filename: String) =
Attachment(messageId, partIndex, filename, "application/pdf", 1_000L)
/** Renders [ReaderScreen] for the fixed [message] with the given [attachments] and awaits load. */
private fun renderReader(attachments: List<Attachment>, downloadedParts: Set<Int> = emptySet()) {
val context = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext
val repo = FakeMailRepository(
messages = listOf(message),
attachments = attachments,
downloadedParts = downloadedParts,
)
val viewModel = ReaderViewModel(
SavedStateHandle(mapOf(Routes.READER_ARG_ID to messageId)),
repo,
SettingsRepository(context),
)
composeTestRule.setContent {
LibreMailTheme(darkTheme = false, dynamicColor = false) {
ReaderScreen(onBack = {}, onReply = { _, _, _ -> }, viewModel = viewModel)
}
}
composeTestRule.waitUntil(5_000) {
composeTestRule.onAllNodesWithText(attachments.first().filename).fetchSemanticsNodes().isNotEmpty()
}
}
@Test
fun reader_showsDownloadedIndicator_forCachedAttachment() {
renderReader(listOf(attachment(0, "report.pdf")), downloadedParts = setOf(0))
composeTestRule.onNodeWithText("report.pdf").assertIsDisplayed()
composeTestRule.onNodeWithContentDescription(string(R.string.attachment_downloaded)).assertIsDisplayed()
}
@Test
fun reader_singleAttachment_showsNoAccordion() {
renderReader(listOf(attachment(0, "solo.pdf")))
composeTestRule.onNodeWithText("solo.pdf").assertIsDisplayed()
// A lone attachment has no "See more" control.
composeTestRule.onNodeWithContentDescription(string(R.string.attachments_expand)).assertDoesNotExist()
}
@Test
fun reader_multipleAttachments_collapseExtrasUntilExpanded() {
renderReader(listOf(attachment(0, "one.pdf"), attachment(1, "two.pdf"), attachment(2, "three.pdf")))
// First row shown; the two extras are hidden behind the collapsed accordion.
composeTestRule.onNodeWithText("one.pdf").assertIsDisplayed()
composeTestRule.onNodeWithText(seeMore(2)).assertIsDisplayed()
composeTestRule.onNodeWithText("two.pdf").assertDoesNotExist()
composeTestRule.onNodeWithText("three.pdf").assertDoesNotExist()
// Tapping the control reveals the remaining rows.
composeTestRule.onNodeWithText(seeMore(2)).performClick()
composeTestRule.waitUntil(5_000) {
composeTestRule.onAllNodesWithText("two.pdf").fetchSemanticsNodes().isNotEmpty()
}
composeTestRule.onNodeWithText("two.pdf").assertIsDisplayed()
composeTestRule.onNodeWithText("three.pdf").assertIsDisplayed()
}
@Test
fun reader_twoAttachments_useSingularPlural() {
renderReader(listOf(attachment(0, "a.pdf"), attachment(1, "b.pdf")))
// Exactly one extra: the singular plural form, e.g. "See 1 more attachment".
composeTestRule.onNodeWithText(seeMore(1)).assertIsDisplayed()
composeTestRule.onNodeWithText("b.pdf").assertDoesNotExist()
}
}
@@ -0,0 +1,108 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.ui.settings
import android.content.Context
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.lifecycle.SavedStateHandle
import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.work.WorkManager
import kotlinx.coroutines.runBlocking
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.libremail.R
import org.libremail.data.local.AccountDatabase
import org.libremail.data.local.toEntity
import org.libremail.data.settings.AccountSettingsRepository
import org.libremail.data.settings.SettingsRepository
import org.libremail.data.settings.SignatureRepository
import org.libremail.data.sync.SyncScheduler
import org.libremail.domain.model.Account
import org.libremail.domain.model.AuthType
import org.libremail.domain.model.MailSecurity
import org.libremail.domain.model.ServerConfig
import org.libremail.ui.FakeAccountRepository
import org.libremail.ui.navigation.Routes
import org.libremail.ui.theme.LibreMailTheme
import javax.inject.Provider
/**
* End-to-end test for the per-account settings screen: editing the signature and toggling the
* per-account notification switch must round-trip through the real Room-backed repository.
*/
@RunWith(AndroidJUnit4::class)
class AccountSettingsScreenTest {
@get:Rule
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
private val account = Account(
id = "imap:me@example.com",
email = "me@example.com",
displayName = "Me",
authType = AuthType.PASSWORD_IMAP,
imap = ServerConfig("imap.example.com", 993, MailSecurity.SSL_TLS),
smtp = ServerConfig("smtp.example.com", 465, MailSecurity.SSL_TLS),
)
private fun string(resId: Int) = composeTestRule.activity.getString(resId)
private var manageSignaturesClicked = false
private fun setContent(): AccountSettingsRepository {
val context = ApplicationProvider.getApplicationContext<Context>()
// Intentionally not closed in an @After: the ViewModel's `settings` Room Flow (kept alive by
// stateIn/WhileSubscribed) keeps querying after the test body, so closing the in-memory DB out
// from under it races and crashes ("connection pool has been closed"). The DB is reclaimed with
// the test process.
val db = Room.inMemoryDatabaseBuilder(context, AccountDatabase::class.java).build()
val repository = AccountSettingsRepository(db.accountSettingsDao())
runBlocking {
db.accountDao().upsert(account.toEntity()) // FK parent for the account_settings row
repository.ensureDefaults(account.id)
}
val viewModel = AccountSettingsViewModel(
savedStateHandle = SavedStateHandle(mapOf(Routes.ACCOUNT_SETTINGS_ARG_ID to account.id)),
accountRepository = FakeAccountRepository(accounts = listOf(account)),
accountSettingsRepository = repository,
signatureRepository = SignatureRepository(db.signatureDao()),
syncScheduler = SyncScheduler(Provider { WorkManager.getInstance(context) }),
settingsRepository = SettingsRepository(context),
)
composeTestRule.setContent {
LibreMailTheme(darkTheme = false, dynamicColor = false) {
AccountSettingsScreen(
onBack = {},
onManageSignatures = { manageSignaturesClicked = true },
viewModel = viewModel,
)
}
}
return repository
}
@Test
fun manageSignatures_opensTheSignaturesScreen() {
setContent()
composeTestRule.onNodeWithText(string(R.string.settings_signatures_manage)).performClick()
composeTestRule.waitUntil(5_000) { manageSignaturesClicked }
}
@Test
fun togglingNotifications_persistsThroughTheRepository() {
val repository = setContent() // ensureDefaults starts notifications enabled
composeTestRule.onNodeWithText(string(R.string.settings_account_new_mail)).performClick()
composeTestRule.waitUntil(5_000) {
runBlocking { !repository.get(account.id).notificationsEnabled }
}
}
}
@@ -0,0 +1,62 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.ui.settings
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.libremail.R
import org.libremail.contacts.ContactPermissionState
import org.libremail.ui.theme.LibreMailTheme
/**
* UI tests for the Settings contacts-autocomplete row (#129). The row is presentational, so each of
* its three states — on / off / blocked-in-settings — is driven directly and asserted deterministically,
* independent of the process's real `READ_CONTACTS` grant.
*/
@RunWith(AndroidJUnit4::class)
class ContactAutocompleteRowTest {
@get:Rule
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
private fun string(resId: Int) = composeTestRule.activity.getString(resId)
private fun setContent(state: ContactPermissionState, onClick: () -> Unit = {}) {
composeTestRule.setContent {
LibreMailTheme(darkTheme = false, dynamicColor = false) {
ContactAutocompleteRow(state = state, onClick = onClick)
}
}
}
@Test
fun granted_showsOnSubtitle_andIsClickable() {
var clicked = false
setContent(ContactPermissionState.GRANTED) { clicked = true }
composeTestRule.onNodeWithText(string(R.string.settings_contacts_autocomplete)).assertIsDisplayed()
composeTestRule.onNodeWithText(string(R.string.settings_contacts_autocomplete_on)).assertIsDisplayed()
composeTestRule.onNodeWithText(string(R.string.settings_contacts_autocomplete_on)).performClick()
assertTrue("Tapping the row must invoke onClick", clicked)
}
@Test
fun denied_showsOffSubtitle() {
setContent(ContactPermissionState.DENIED)
composeTestRule.onNodeWithText(string(R.string.settings_contacts_autocomplete_off)).assertIsDisplayed()
}
@Test
fun blocked_showsBlockedSubtitle() {
setContent(ContactPermissionState.BLOCKED)
composeTestRule.onNodeWithText(string(R.string.settings_contacts_autocomplete_blocked)).assertIsDisplayed()
}
}
@@ -0,0 +1,119 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.ui.settings
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import androidx.work.WorkManager
import kotlinx.coroutines.runBlocking
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.libremail.R
import org.libremail.contacts.ContactsPermissionManager
import org.libremail.data.security.AppLockManager
import org.libremail.data.security.DatabaseKeyCipher
import org.libremail.data.security.DatabaseKeyStore
import org.libremail.data.security.KeystoreCrypto
import org.libremail.data.security.PassphraseSession
import org.libremail.data.settings.FetchPolicy
import org.libremail.data.settings.SettingsRepository
import org.libremail.data.sync.SyncScheduler
import org.libremail.push.BatteryOptimizationManager
import org.libremail.ui.FakeAccountRepository
import org.libremail.ui.theme.LibreMailTheme
import javax.inject.Provider
/**
* End-to-end tests for the global settings screen. Tapping a policy must round-trip through the real
* [SettingsRepository] (DataStore), proving the UI → ViewModel → persistence wiring; and enabling
* app-lock on a device with no secure lock must surface the rejection message via a snackbar — now
* visible to Compose semantics, unlike the former Toast it replaced.
*/
@RunWith(AndroidJUnit4::class)
class SettingsScreenTest {
@get:Rule
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
private val context = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext
// Device reports no secure lock, so enabling app-lock is rejected with a message.
private val insecureDevice = object : AppLockManager {
override fun isDeviceSecure() = false
}
private fun string(resId: Int) = composeTestRule.activity.getString(resId)
private fun settingsViewModel(settingsRepository: SettingsRepository): SettingsViewModel {
val keyStore = DatabaseKeyStore(context, KeystoreCrypto(), DatabaseKeyCipher(), PassphraseSession())
return SettingsViewModel(
FakeAccountRepository(),
settingsRepository,
insecureDevice,
keyStore,
BatteryOptimizationManager(context),
ContactsPermissionManager(context),
SyncScheduler(Provider { WorkManager.getInstance(context) }),
)
}
private fun setContent(viewModel: SettingsViewModel) {
composeTestRule.setContent {
LibreMailTheme(darkTheme = false, dynamicColor = false) {
SettingsScreen(
onAddAccount = {},
onOpenAccount = {},
onSelectTab = {},
onReportProblem = {},
viewModel = viewModel,
)
}
}
}
@Test
fun selectingFetchPolicy_persistsThroughTheRepository() {
val settingsRepository = SettingsRepository(context)
runBlocking { settingsRepository.setFetchPolicy(FetchPolicy.ALWAYS) } // known starting state
setContent(settingsViewModel(settingsRepository))
composeTestRule.onNodeWithText(string(R.string.fetch_on_demand)).performScrollTo().performClick()
composeTestRule.waitUntil(5_000) {
runBlocking { settingsRepository.fetchPolicy() } == FetchPolicy.ON_DEMAND
}
}
@Test
fun contactsAutocompleteRow_isShown() {
// The contacts entry (#129) is wired into the real screen; it reflects the live permission
// state, so we assert only that the row is present (state-specific rendering is covered by
// ContactAutocompleteRowTest).
setContent(settingsViewModel(SettingsRepository(context)))
composeTestRule.onNodeWithText(string(R.string.settings_contacts_autocomplete))
.performScrollTo().assertIsDisplayed()
}
@Test
fun enablingAppLockWithoutSecureDevice_showsRejectionSnackbar() {
val settingsRepository = SettingsRepository(context)
runBlocking { settingsRepository.setAppLock(false) } // known starting state: off
setContent(settingsViewModel(settingsRepository))
// App-lock lives under the collapsed "Advanced" section: expand it, then toggle the switch on.
composeTestRule.onNodeWithText(string(R.string.settings_advanced)).performScrollTo().performClick()
composeTestRule.onNodeWithText(string(R.string.settings_adv_app_lock)).performScrollTo().performClick()
val message = string(R.string.app_lock_needs_device_lock)
composeTestRule.waitUntil(5_000) {
composeTestRule.onAllNodesWithText(message).fetchSemanticsNodes().isNotEmpty()
}
}
}
+68 -13
View File
@@ -4,15 +4,25 @@
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.READ_CONTACTS" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<!-- Android Backup is opt-in and OFF by default (issue #21). allowBackup can't be toggled at
runtime, so LibreMailBackupAgent gates it on the "Include settings in Android Backup"
preference: with backup disabled the agent ships nothing. When enabled, only the settings
DataStore is backed up per the allowlist in data_extraction_rules (API 31+) /
backup_rules (API 29-30) — never credentials, the mail cache, or the Keystore-sealed cache
passphrase. fullBackupOnly keeps this to Auto Backup (full-data) only. -->
<application
android:name=".LibreMailApplication"
android:allowBackup="false"
android:allowBackup="true"
android:backupAgent=".backup.LibreMailBackupAgent"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:fullBackupOnly="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
@@ -28,6 +38,38 @@
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- Handle mailto: links tapped in browsers and other apps (ACTION_VIEW), which is also
what makes LibreMail appear on the system "Open by default" / default-apps screen
where the platform exposes an email association. Android has no public RoleManager
email role, so becoming the system default is OEM-dependent — hence "where supported". -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="mailto" />
</intent-filter>
<!-- "Send email to <address>" targets from other apps (contacts, dialer, etc.). -->
<intent-filter>
<action android:name="android.intent.action.SENDTO" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="mailto" />
</intent-filter>
<!-- Share-to-email: text and RFC-822 email payloads with recipients/subject/body extras. -->
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/plain" />
<data android:mimeType="message/rfc822" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND_MULTIPLE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="message/rfc822" />
</intent-filter>
</activity>
<!-- WorkManager is initialized on-demand via Configuration.Provider, so remove the
@@ -49,6 +91,19 @@
android:exported="false"
android:foregroundServiceType="dataSync" />
<!-- Separate-process trampoline for the app-lock key-invalidation recovery restart. Runs in
its own ":restart" process (android:process) so it survives the main process being killed
and can reliably relaunch the app from the outside — a same-process "startActivity then
exit(0)" restart races ActivityManager and can be dropped (the ProcessPhoenix pattern).
Not exported; only ProcessRestarter starts it. Translucent + excluded from recents so it
never flashes UI or lingers in the switcher before it finishes and exits its own process. -->
<activity
android:name=".restart.RestartActivity"
android:excludeFromRecents="true"
android:exported="false"
android:process=":restart"
android:theme="@android:style/Theme.Translucent.NoTitleBar" />
<!-- Shares downloaded attachments with viewer apps via a content:// URI. -->
<provider
android:name="androidx.core.content.FileProvider"
@@ -60,20 +115,20 @@
android:resource="@xml/file_paths" />
</provider>
<!-- Captures the Microsoft OAuth redirect for Outlook sign-in. AppAuth registers the
Gmail scheme via ${appAuthRedirectScheme}; this adds the Outlook scheme. The redirect
URI org.libremail.outlook://oauth2redirect must be registered as a public-client
(mobile/desktop) redirect in the Azure app registration. -->
<!-- Captures the Microsoft OAuth redirect for Outlook sign-in. AppAuth's bundled manifest
already declares RedirectUriReceiverActivity with an intent-filter for
${appAuthRedirectScheme}, which build.gradle.kts sets to the Outlook scheme
(org.libremail.outlook); the redirect URI org.libremail.outlook://oauth2redirect must
be registered as a public-client (mobile/desktop) redirect in the Azure app
registration. We only merge a theme onto that activity here: AppAuth declares no theme
and RedirectUriReceiverActivity extends AppCompatActivity, so without an AppCompat theme
it inherits the app's Theme.Material shell and crashes ("You need to use a Theme.AppCompat
theme") when the redirect launches it. We reuse the translucent theme AppAuth itself
applies to AuthorizationManagementActivity. -->
<activity
android:name="net.openid.appauth.RedirectUriReceiverActivity"
android:exported="true"
tools:node="merge">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="org.libremail.outlook" />
</intent-filter>
</activity>
android:theme="@style/Theme.AppCompat.Translucent.NoTitleBar"
tools:node="merge" />
</application>
</manifest>
@@ -4,8 +4,8 @@ package org.libremail
import android.app.Application
import androidx.hilt.work.HiltWorkerFactory
import androidx.work.Configuration
import dagger.Lazy
import dagger.hilt.android.HiltAndroidApp
import javax.inject.Inject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@@ -17,9 +17,17 @@ import org.libremail.data.settings.SettingsRepository
import org.libremail.data.sync.SyncScheduler
import org.libremail.domain.repository.AccountRepository
import org.libremail.push.IdlePushManager
import org.libremail.reporting.AppLog
import org.libremail.reporting.CrashReporter
import org.libremail.reporting.DiagnosticsCollector
import org.libremail.reporting.RingLogBuffer
import org.libremail.restart.ProcessRestarter
import javax.inject.Inject
@HiltAndroidApp
class LibreMailApplication : Application(), Configuration.Provider {
class LibreMailApplication :
Application(),
Configuration.Provider {
@Inject lateinit var workerFactory: HiltWorkerFactory
@@ -27,10 +35,19 @@ class LibreMailApplication : Application(), Configuration.Provider {
@Inject lateinit var settingsRepository: SettingsRepository
@Inject lateinit var accountRepository: AccountRepository
// Lazy: resolving AccountRepository constructs the Room database, which — with app-lock + encrypted
// cache on — blocks until the user authenticates. Keeping it lazy means the DB is built off the main
// thread inside the collector below (never during onCreate), so the app never deadlocks at launch.
@Inject lateinit var accountRepository: Lazy<AccountRepository>
@Inject lateinit var idlePushManager: IdlePushManager
@Inject lateinit var ringLogBuffer: RingLogBuffer
@Inject lateinit var crashReporter: CrashReporter
@Inject lateinit var diagnosticsCollector: DiagnosticsCollector
private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
/** Whether the IDLE push service should currently be running (push enabled AND an account exists). */
@@ -44,14 +61,31 @@ class LibreMailApplication : Application(), Configuration.Provider {
override fun onCreate() {
super.onCreate()
// Android also instantiates this Application in the separate ":restart" trampoline process
// (see ProcessRestarter / RestartActivity), which exists only to relaunch the app from outside
// a dying main process and is torn down within milliseconds. It must NOT run any of the app's
// normal startup work — crash reporting, WorkManager scheduling, IDLE push all belong to the
// main process. The main process (no ":restart" suffix) is unaffected, so normal launch is too.
if (isRestartTrampolineProcess()) return
// Wire up debug reporting first so crashes during the rest of startup are still captured.
AppLog.install(ringLogBuffer)
crashReporter.install()
AppLog.i(TAG, "Application created")
// Warm the settings cache so a later crash report can include non-PII settings without
// touching DataStore on the crashing thread.
appScope.launch { runCatching { diagnosticsCollector.warmSettingsCache() } }
syncScheduler.schedulePeriodicSync()
// Full-history backfill (#12) and device-only retention pruning (#13) run as their own bounded,
// resumable background jobs so they never block foreground sync / pull-to-refresh.
syncScheduler.schedulePeriodicBackfill()
syncScheduler.schedulePeriodicPrune()
// Run the IMAP IDLE push service only while it has something to do: the push setting is on
// AND at least one account exists. This starts it when the first account is added and stops
// it when the last is removed, reactively.
appScope.launch {
combine(
settingsRepository.settings.map { it.pushIdle },
accountRepository.observeAccounts().map { it.isNotEmpty() },
accountRepository.get().observeAccounts().map { it.isNotEmpty() },
) { pushEnabled, hasAccounts -> pushEnabled && hasAccounts }
.distinctUntilChanged()
.collect { active ->
@@ -70,4 +104,12 @@ class LibreMailApplication : Application(), Configuration.Provider {
fun ensurePushStarted() {
if (pushShouldBeActive) idlePushManager.start()
}
/** True when this Application instance is the one Android spun up in the ":restart" aux process. */
private fun isRestartTrampolineProcess(): Boolean =
Application.getProcessName() == packageName + ProcessRestarter.PROCESS_SUFFIX
private companion object {
const val TAG = "LibreMail"
}
}
+104 -23
View File
@@ -1,32 +1,50 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail
import android.Manifest
import android.content.pm.PackageManager
import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.rememberLauncherForActivityResult
import android.view.WindowManager
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.platform.LocalContext
import androidx.core.content.ContextCompat
import androidx.compose.runtime.mutableStateOf
import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import org.libremail.data.settings.SettingsRepository
import org.libremail.notifications.NotificationIntents
import org.libremail.ui.LibreMailApp
import org.libremail.ui.compose.ComposePrefill
import org.libremail.ui.compose.IntentComposeParser
import org.libremail.ui.lock.AppLockGateHost
import org.libremail.ui.theme.LibreMailTheme
import javax.inject.Inject
// FragmentActivity (not ComponentActivity) because BiometricPrompt requires one for the app-lock
// flow. FragmentActivity extends androidx.activity.ComponentActivity, so setContent / enableEdgeToEdge
// and Hilt injection keep working unchanged.
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
class MainActivity : FragmentActivity() {
@Inject
lateinit var settingsRepository: SettingsRepository
/**
* A pending compose request parsed from a `mailto:` / share intent, consumed once by the NavHost.
* Held as Compose state so [onNewIntent] can re-trigger it while the activity is alive.
*/
private val pendingCompose = mutableStateOf<ComposePrefill?>(null)
/**
* The message a tapped new-mail notification asks to open, consumed once by the NavHost. Compose
* state for the same reason as [pendingCompose].
*/
private val pendingOpenMessageId = mutableStateOf<String?>(null)
override fun onStart() {
super.onStart()
// Foreground: recover IDLE push if a background start was previously blocked.
@@ -36,24 +54,87 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
// Block screenshots and the recents-switcher snapshot while app-lock is on — the Compose gate
// can't stop the system's task snapshot (captured around background). Gated on the setting
// because FLAG_SECURE also blocks the user's own screenshots.
lifecycleScope.launch {
settingsRepository.settings.map { it.appLock }.distinctUntilChanged().collect { secure ->
if (secure) {
window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
} else {
window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
}
}
}
handleIntent(intent)
setContent {
val dynamicColor by settingsRepository.dynamicColor.collectAsStateWithLifecycle(initialValue = true)
LibreMailTheme(dynamicColor = dynamicColor) {
NotificationPermissionEffect()
LibreMailApp()
// Gate the whole app behind the screen-lock when app-lock is enabled. When it is off
// the gate resolves straight to the content, so this is a no-op for most users.
AppLockGateHost {
LibreMailApp(
pendingCompose = pendingCompose.value,
onComposeHandled = { pendingCompose.value = null },
pendingOpenMessageId = pendingOpenMessageId.value,
onOpenMessageHandled = { pendingOpenMessageId.value = null },
)
}
}
}
}
}
/** Requests POST_NOTIFICATIONS once on first launch (no-op if already granted). */
@Composable
private fun NotificationPermissionEffect() {
val context = LocalContext.current
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) {}
LaunchedEffect(Unit) {
val granted = ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) ==
PackageManager.PERMISSION_GRANTED
if (!granted) launcher.launch(Manifest.permission.POST_NOTIFICATIONS)
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
handleIntent(intent)
}
/**
* Parses [intent] for a pending compose ([IntentComposeParser]) or open-message
* ([NotificationIntents]) request — unless [IntentHandledMarker] says this exact intent instance
* was already parsed.
*
* This used to be gated on `savedInstanceState == null` in [onCreate]: a non-null value was
* assumed to mean "config-change recreation" — where Android redelivers the very same,
* already-parsed intent and the NavHost restores its own compose/reader destination itself, so
* re-parsing would only navigate to a duplicate. But Android *also* passes a restored, non-null
* savedInstanceState when it recreates this activity after the process was killed in the
* background and is then relaunched — e.g. by tapping a notification. There, `intent` is the new
* tap, not a replay, but the old guard swallowed it exactly like a rotation: `pendingOpenMessageId`
* was never set, so the tap silently landed wherever the restored back stack was, never the
* message. That regression is #157.
*
* Marking the [Intent] instance itself — rather than branching on savedInstanceState, which can't
* tell a config change and a process-death relaunch apart — is correct for both: a config-change
* recreation redelivers the very same intent this activity already marked, so it's recognized and
* skipped; a genuinely new intent — a fresh notification tap or mailto/share, whether delivered
* warm via [onNewIntent] or cold via [onCreate] after a process-death relaunch — is never marked
* yet, so it's always (re)parsed.
*/
private fun handleIntent(intent: Intent) {
if (!IntentHandledMarker.markIfUnhandled(intent)) return
IntentComposeParser.parse(intent)?.let { pendingCompose.value = it }
NotificationIntents.messageId(intent)?.let { pendingOpenMessageId.value = it }
}
}
/**
* Marks an [Intent] as already parsed by [MainActivity.handleIntent], so a redelivery of the very same
* instance — which is what Android does when it recreates an Activity for a configuration change — is
* recognized and skipped instead of re-triggering a duplicate navigation. A freshly constructed intent
* (a new notification tap or mailto/share, however it arrives) is never marked yet, so it is always
* treated as unhandled — including right after a process-death relaunch, where `savedInstanceState` is
* restored (non-null) but the intent itself is new. Internal (not private) so androidTest can verify
* the marking contract directly. See #157.
*/
internal object IntentHandledMarker {
private const val EXTRA_HANDLED = "org.libremail.extra.INTENT_HANDLED"
/** Marks [intent] handled and returns `true` — but only the first time this instance is seen. */
fun markIfUnhandled(intent: Intent): Boolean {
if (intent.getBooleanExtra(EXTRA_HANDLED, false)) return false
intent.putExtra(EXTRA_HANDLED, true)
return true
}
}
@@ -1,114 +0,0 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.auth
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.util.Base64
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlinx.coroutines.suspendCancellableCoroutine
import net.openid.appauth.AuthState
import net.openid.appauth.AuthorizationException
import net.openid.appauth.AuthorizationRequest
import net.openid.appauth.AuthorizationResponse
import net.openid.appauth.AuthorizationService
import net.openid.appauth.AuthorizationServiceConfiguration
import net.openid.appauth.ResponseTypeValues
import net.openid.appauth.TokenResponse
import org.json.JSONObject
import org.libremail.BuildConfig
/**
* Gmail OAuth 2.0 via AppAuth — Authorization Code + PKCE, no client secret. The restricted
* `https://mail.google.com/` scope is requested so the access token works for IMAP/SMTP XOAUTH2.
*/
@Singleton
class GmailAuthManager @Inject constructor(
@ApplicationContext private val context: Context,
) {
private val serviceConfig = AuthorizationServiceConfiguration(
Uri.parse("https://accounts.google.com/o/oauth2/v2/auth"),
Uri.parse("https://oauth2.googleapis.com/token"),
)
/** False until a Google OAuth client id is provided in secrets.properties (see README). */
val isConfigured: Boolean get() = BuildConfig.GMAIL_OAUTH_CLIENT_ID.isNotBlank()
fun createAuthIntent(): Intent {
val request = AuthorizationRequest.Builder(
serviceConfig,
BuildConfig.GMAIL_OAUTH_CLIENT_ID,
ResponseTypeValues.CODE,
Uri.parse(BuildConfig.GMAIL_OAUTH_REDIRECT_URI),
)
.setScope("openid email profile https://mail.google.com/")
.build()
return AuthorizationService(context).getAuthorizationRequestIntent(request)
}
suspend fun exchangeToken(responseIntent: Intent): OAuthResult {
val response = AuthorizationResponse.fromIntent(responseIntent)
val exception = AuthorizationException.fromIntent(responseIntent)
if (response == null) throw exception ?: IllegalStateException("Authorization was cancelled")
val service = AuthorizationService(context)
try {
val tokenResponse = suspendCancellableCoroutine { continuation ->
service.performTokenRequest(response.createTokenExchangeRequest()) { token, error ->
if (token != null) {
continuation.resume(token)
} else {
continuation.resumeWithException(error ?: IllegalStateException("Token exchange failed"))
}
}
}
val authState = AuthState(response, exception).apply { update(tokenResponse, null) }
val email = emailFromIdToken(tokenResponse.idToken)
?: throw IllegalStateException("Could not read the account email from the token")
return OAuthResult(
email = email,
accessToken = tokenResponse.accessToken.orEmpty(),
authStateJson = authState.jsonSerializeString(),
)
} finally {
service.dispose()
}
}
/** Refreshes the access token if needed (using the stored AuthState) for IMAP/SMTP XOAUTH2. */
suspend fun freshAccessToken(authStateJson: String): FreshToken {
val authState = AuthState.jsonDeserialize(authStateJson)
val service = AuthorizationService(context)
try {
val accessToken = suspendCancellableCoroutine { continuation ->
authState.performActionWithFreshTokens(service) { token, _, error ->
if (token != null) {
continuation.resume(token)
} else {
continuation.resumeWithException(error ?: IllegalStateException("Token refresh failed"))
}
}
}
return FreshToken(
accessToken = accessToken,
authStateJson = authState.jsonSerializeString(),
accessTokenExpiry = authState.accessTokenExpirationTime,
)
} finally {
service.dispose()
}
}
private fun emailFromIdToken(idToken: String?): String? {
if (idToken.isNullOrBlank()) return null
return runCatching {
val payload = idToken.split(".").getOrNull(1) ?: return null
val json = String(Base64.decode(payload, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP))
JSONObject(json).optString("email").ifBlank { null }
}.getOrNull()
}
}
@@ -6,10 +6,6 @@ import android.content.Intent
import android.net.Uri
import android.util.Base64
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlinx.coroutines.suspendCancellableCoroutine
import net.openid.appauth.AuthState
import net.openid.appauth.AuthorizationException
@@ -22,6 +18,10 @@ import net.openid.appauth.ResponseTypeValues
import net.openid.appauth.TokenRequest
import org.json.JSONObject
import org.libremail.BuildConfig
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
/**
* Outlook / Microsoft OAuth 2.0 via AppAuth — Authorization Code + PKCE, no client secret.
@@ -34,9 +34,7 @@ import org.libremail.BuildConfig
* both personal Microsoft accounts and work/school (Microsoft 365).
*/
@Singleton
class OutlookAuthManager @Inject constructor(
@ApplicationContext private val context: Context,
) {
class OutlookAuthManager @Inject constructor(@ApplicationContext private val context: Context) {
private val serviceConfig = AuthorizationServiceConfiguration(
Uri.parse("https://login.microsoftonline.com/common/oauth2/v2.0/authorize"),
Uri.parse("https://login.microsoftonline.com/common/oauth2/v2.0/token"),
@@ -45,6 +43,12 @@ class OutlookAuthManager @Inject constructor(
/** Outlook is always available: the Microsoft client id ships with the build (it is not a secret). */
val isConfigured: Boolean get() = BuildConfig.OUTLOOK_OAUTH_CLIENT_ID.isNotBlank()
/**
* Builds the browser/Custom-Tab intent that starts the Microsoft sign-in.
*
* Throws [android.content.ActivityNotFoundException] when no usable browser is installed;
* callers must guard the launch and surface that as an error rather than crashing.
*/
fun createAuthIntent(): Intent {
val request = AuthorizationRequest.Builder(
serviceConfig,
@@ -55,18 +59,43 @@ class OutlookAuthManager @Inject constructor(
// One consent covering both resources; per-resource access tokens are minted later.
.setScope("openid email $OFFLINE $GRAPH_SCOPE $OUTLOOK_SCOPE")
.build()
return AuthorizationService(context).getAuthorizationRequestIntent(request)
// The returned intent is self-contained, so dispose the service (and its Custom-Tabs
// warmup binding) immediately instead of leaking one per button tap.
val service = AuthorizationService(context)
return try {
service.getAuthorizationRequestIntent(request)
} finally {
service.dispose()
}
}
suspend fun exchangeToken(responseIntent: Intent): OAuthResult {
val response = AuthorizationResponse.fromIntent(responseIntent)
val exception = AuthorizationException.fromIntent(responseIntent)
if (response == null) throw exception ?: IllegalStateException("Authorization was cancelled")
val authCode = response.authorizationCode
?: throw exception ?: IllegalStateException("No authorization code was returned")
// Microsoft issues one access token per resource, so the authorization-code exchange must
// name a single resource. AppAuth's createTokenExchangeRequest() sends no scope, which makes
// Microsoft reject our multi-resource consent code with AADSTS70011 ("must include a 'scope'
// input parameter"). Request the OIDC scopes plus the Exchange Online resource so we still get
// an id_token (for the email) and a refresh token to mint the Graph token from later.
// This mirrors every field createTokenExchangeRequest() sets — including the nonce, which
// AppAuth checks against the id_token's nonce claim (omitting it fails id_token validation).
val exchangeRequest = TokenRequest.Builder(serviceConfig, BuildConfig.OUTLOOK_OAUTH_CLIENT_ID)
.setGrantType(GrantTypeValues.AUTHORIZATION_CODE)
.setAuthorizationCode(authCode)
.setRedirectUri(Uri.parse(BuildConfig.OUTLOOK_OAUTH_REDIRECT_URI))
.setCodeVerifier(response.request.codeVerifier)
.setNonce(response.request.nonce)
.setScope("openid email $OFFLINE $OUTLOOK_SCOPE")
.build()
val service = AuthorizationService(context)
try {
val tokenResponse = suspendCancellableCoroutine { continuation ->
service.performTokenRequest(response.createTokenExchangeRequest()) { token, error ->
service.performTokenRequest(exchangeRequest) { token, error ->
if (token != null) {
continuation.resume(token)
} else {
@@ -0,0 +1,44 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.backup
import org.libremail.data.local.DatabaseFiles
import org.libremail.data.settings.AppSettings
/**
* Single source of truth for what LibreMail is willing to hand to Android Backup. Kept in lockstep
* with `res/xml/data_extraction_rules.xml` (API 31+) and `res/xml/backup_rules.xml` (API 29-30); the
* path constants here are asserted against those resources by `DataExtractionRulesTest`.
*
* Only re-creatable user preferences are eligible. The mail cache re-downloads on the next sync, and
* the credentials plus the Keystore-sealed cache passphrase are device-bound secrets that would only
* ever restore as undecryptable ciphertext — so they are never backed up.
*/
object BackupPolicy {
/** `filesDir`-relative DataStore file holding user preferences — the only data we back up. */
const val SAFE_SETTINGS_FILE: String = "datastore/libremail_settings.preferences_pb"
/** `filesDir`-relative paths that must never leave the device. */
val EXCLUDED_FILE_PATHS: List<String> = listOf(
// Keystore-sealed SQLCipher passphrase for the encrypted cache: the wrapping key is
// non-exportable and device-bound, so this ciphertext is useless anywhere else.
"datastore/libremail_dbkey.preferences_pb",
)
/**
* `databases`-dir-relative names that must never leave the device: the encrypted mail cache
* ([DatabaseFiles.NAME]) AND the accounts + encrypted-credentials database
* ([DatabaseFiles.ACCOUNTS_NAME]), each with its SQLite sidecars. Derived from [DatabaseFiles]
* rather than hand-listed, so a newly added database can never silently fall out of the
* never-back-up set (issue #103).
*/
val EXCLUDED_DATABASE_PATHS: List<String> =
DatabaseFiles.fileNames(DatabaseFiles.NAME) +
DatabaseFiles.fileNames(DatabaseFiles.ACCOUNTS_NAME)
/**
* Whether Android Backup may run for this app. Opt-in and OFF by default: nothing is backed up
* (or transferred device-to-device) unless the user has explicitly enabled it in Settings.
*/
fun shouldBackUp(settings: AppSettings): Boolean = settings.includeInBackup
}
@@ -0,0 +1,42 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.backup
import android.app.backup.BackupAgentHelper
import android.app.backup.FullBackupDataOutput
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import org.libremail.data.settings.settingsDataStore
import org.libremail.data.settings.toAppSettings
/**
* Enforces the runtime backup opt-in on top of Android Auto Backup.
*
* `android:allowBackup` is a manifest flag that can't be toggled at runtime, so the "include settings
* in Android Backup" preference is enforced here instead: [onFullBackup] runs the backup only when the
* user has opted in (the default is off, so no app data leaves the device). When opted in, it defers to
* the framework, which applies the allowlist in `res/xml/data_extraction_rules.xml` (and
* `res/xml/backup_rules.xml` on API < 31) — backing up the user-preferences DataStore only, never the
* mail cache, the encrypted credentials, or the Keystore-sealed cache passphrase.
*
* Extends [BackupAgentHelper] (rather than raw `BackupAgent`) so the unused key/value backup/restore
* paths inherit safe no-op implementations; only full-data backup is used (`fullBackupOnly=true`), and
* full-data restore uses the default `onRestoreFile` handling.
*
* The opt-in flag is read directly from the shared [settingsDataStore] singleton so it does not depend
* on Hilt or `Application.onCreate` having run in the framework's restricted backup mode.
*/
class LibreMailBackupAgent : BackupAgentHelper() {
override fun onFullBackup(data: FullBackupDataOutput) {
if (backupOptedIn()) {
super.onFullBackup(data)
}
}
/** Reads the opt-in flag; any failure defaults to "not opted in" so we never back up by accident. */
private fun backupOptedIn(): Boolean = runCatching {
runBlocking {
BackupPolicy.shouldBackUp(applicationContext.settingsDataStore.data.first().toAppSettings())
}
}.getOrDefault(false)
}
@@ -0,0 +1,37 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.contacts
/**
* Where the optional contacts-autocomplete permission stands, as the Settings entry (#129) shows it.
* - [GRANTED]: on — recipient autocomplete works.
* - [DENIED]: off but re-requestable in-app (never asked, or denied once without "don't ask again").
* - [BLOCKED]: off and no longer re-requestable — the only way back is the system settings screen.
*/
enum class ContactPermissionState { GRANTED, DENIED, BLOCKED }
/**
* Pure mapping from the three Android permission signals to a [ContactPermissionState]. Kept free of
* Android types so it is exhaustively unit-testable; the live inputs are read by
* [ContactsPermissionManager] (grant), the Activity (`shouldShowRequestPermissionRationale`), and
* [org.libremail.data.settings.SettingsRepository] (whether the system dialog has ever been shown).
*/
object ContactPermissionDecision {
/**
* Resolve the current state:
* - [granted]: `READ_CONTACTS` is held → [ContactPermissionState.GRANTED].
* - [showRationale]: the OS says a rationale should precede a re-request, i.e. the user denied
* once without "don't ask again" → still re-requestable, [ContactPermissionState.DENIED].
* - [alreadyRequested]: the system dialog has been shown before. Combined with `!showRationale`
* (and not granted) this is the permanently-denied case → [ContactPermissionState.BLOCKED].
*
* The remaining case — not granted, no rationale, never requested — is a fresh install that has
* simply never asked, so an in-app request will still surface the dialog: [ContactPermissionState.DENIED].
*/
fun resolve(granted: Boolean, showRationale: Boolean, alreadyRequested: Boolean): ContactPermissionState = when {
granted -> ContactPermissionState.GRANTED
showRationale -> ContactPermissionState.DENIED
alreadyRequested -> ContactPermissionState.BLOCKED
else -> ContactPermissionState.DENIED
}
}
@@ -0,0 +1,39 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.contacts
import android.Manifest
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.provider.Settings
import androidx.core.content.ContextCompat
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
/**
* Reads this app's `READ_CONTACTS` grant and deep-links to the system screen where it can be changed.
* `READ_CONTACTS` powers recipient autocomplete only (see [ContactsRepository]); the whole feature is
* optional and degrades gracefully when the permission is absent.
*
* Deliberately Context-only so it can back both the onboarding opt-in step and the Settings entry.
* The `shouldShowRequestPermissionRationale` signal needs an Activity, so it is read in the Compose
* layer and combined with [ContactPermissionDecision]; this manager stays free of Activity state.
*/
@Singleton
class ContactsPermissionManager @Inject constructor(@ApplicationContext private val context: Context) {
/** True when `READ_CONTACTS` is currently granted to this app. */
fun hasPermission(): Boolean = ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CONTACTS) ==
PackageManager.PERMISSION_GRANTED
/**
* Intent to this app's system details screen, where **Permissions → Contacts** can be toggled.
* Used to recover the permanently-denied ("Don't allow" / don't-ask-again) case, which can no
* longer be re-requested in-app. Always resolvable since API 9.
*/
fun settingsIntent(): Intent = Intent(
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
Uri.fromParts("package", context.packageName, null),
)
}
@@ -4,22 +4,17 @@ package org.libremail.contacts
import android.content.Context
import android.provider.ContactsContract.CommonDataKinds.Email
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import javax.inject.Inject
import javax.inject.Singleton
/** A device contact match for recipient autocomplete. */
data class ContactSuggestion(
val name: String,
val email: String,
)
data class ContactSuggestion(val name: String, val email: String)
/** Looks up device contacts (ContactsContract) for recipient autocomplete. */
@Singleton
class ContactsRepository @Inject constructor(
@ApplicationContext private val context: Context,
) {
class ContactsRepository @Inject constructor(@ApplicationContext private val context: Context) {
/** Returns up to [LIMIT] contacts whose name or email matches [query]. Empty if no permission. */
suspend fun search(query: String): List<ContactSuggestion> = withContext(Dispatchers.IO) {
if (query.length < 2) return@withContext emptyList()
@@ -0,0 +1,15 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.data
import java.io.File
/**
* The per-message on-disk attachment cache directory, keyed by a filesystem-safe form of the message
* id. Shared by the writer ([org.libremail.data.repository.MailRepositoryImpl]) and the retention
* pruner ([org.libremail.data.sync.MailPruner]) so the two can never disagree on where a message's
* attachments live — a divergence would silently leak orphaned files that the pruner no longer finds.
*/
internal fun attachmentCacheDir(cacheDir: File, messageId: String): File {
val safeId = messageId.replace(Regex("[^A-Za-z0-9._-]"), "_")
return File(cacheDir, "attachments/$safeId")
}
@@ -0,0 +1,95 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.data
import org.libremail.domain.model.ReplyMode
import org.libremail.mail.HtmlToText
import org.libremail.mail.ReplyContext
import org.libremail.richtext.RichTextContent
import org.libremail.richtext.RichTextHtml
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/**
* The pre-filled compose fields for a reply/forward. [body] is the plaintext form; [bodyHtml] is the
* matching HTML (the quote rendered as a `<blockquote>`), so the reply can go out as
* `multipart/alternative` without the user having to re-format the quote.
*/
data class ReplyContent(val to: String, val cc: String, val subject: String, val body: String, val bodyHtml: String)
/**
* Pure builder that turns an original message ([ReplyContext]) into the pre-filled compose fields for a
* reply, reply-all, or forward. Kept free of Android/IMAP dependencies so it can be unit-tested directly.
*
* HTML originals are quoted by first reducing them to readable text (via [HtmlToText]) and then
* quoting that — never by prefixing "> " onto raw tags — so the quote can never corrupt the markup.
* The plaintext quote's "> " / attribution structure is then rendered to a clean `<blockquote>` for
* the HTML alternative.
*/
object ReplyBuilder {
fun build(context: ReplyContext, mode: ReplyMode, selfEmail: String): ReplyContent = when (mode) {
ReplyMode.REPLY -> reply(context, cc = "")
ReplyMode.REPLY_ALL -> reply(context, cc = replyAllCc(context, selfEmail).joinToString(", "))
ReplyMode.FORWARD -> {
val body = forwardedBody(context)
ReplyContent(
to = "",
cc = "",
subject = prefixedSubject(context.subject, "Fwd:"),
body = body,
bodyHtml = htmlOf(body),
)
}
}
private fun reply(context: ReplyContext, cc: String): ReplyContent {
val body = quotedReply(context)
return ReplyContent(
to = context.fromEmail,
cc = cc,
subject = prefixedSubject(context.subject, "Re:"),
body = body,
bodyHtml = htmlOf(body),
)
}
/** Everyone on the original To/Cc except ourselves and the original sender (who becomes the To). */
private fun replyAllCc(context: ReplyContext, selfEmail: String): List<String> = (
context.toRecipients +
context.ccRecipients
)
.filter { it.isNotBlank() }
.distinctBy { it.lowercase(Locale.ROOT) }
.filterNot { it.equals(selfEmail, ignoreCase = true) || it.equals(context.fromEmail, ignoreCase = true) }
/** Adds [prefix] unless the subject already starts with it (case-insensitive), avoiding "Re: Re:". */
private fun prefixedSubject(subject: String, prefix: String): String {
val trimmed = subject.trim()
return if (trimmed.startsWith(prefix, ignoreCase = true)) trimmed else "$prefix $trimmed"
}
private fun quotedReply(context: ReplyContext): String {
val original = bodyText(context).lineSequence().joinToString("\n") { "> $it" }
return "\n\nOn ${formatDate(context.sentDateMillis)}, ${context.fromEmail} wrote:\n$original"
}
private fun forwardedBody(context: ReplyContext): String = buildString {
append("\n\n---------- Forwarded message ----------\n")
append("From: ${context.fromEmail}\n")
append("Date: ${formatDate(context.sentDateMillis)}\n")
append("Subject: ${context.subject}\n")
append("To: ${context.toRecipients.joinToString(", ")}\n\n")
append(bodyText(context))
}
/** The original body as plain text (HTML stripped), suitable for quoting in a compose field. */
private fun bodyText(context: ReplyContext): String =
if (context.isHtml) HtmlToText.convert(context.body) else context.body
/** Renders the plaintext quote (with its "> " markers) to the equivalent clean HTML. */
private fun htmlOf(body: String): String = RichTextHtml.toHtml(RichTextContent(body))
private fun formatDate(millis: Long): String =
SimpleDateFormat("MMM d, yyyy, h:mm a", Locale.US).format(Date(millis))
}
@@ -0,0 +1,35 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.data
import org.libremail.domain.model.Signature
import org.libremail.richtext.RichTextContent
import org.libremail.richtext.RichTextHtml
/**
* A signature rendered into both forms the composer needs: [plain] for the plaintext body/fallback
* and [html] for the HTML body. The two are kept in sync — parsing [html] back through the rich-text
* model yields exactly [plain] — so the editor shows the same content whichever it seeds from, and a
* From-account swap can strip the previously applied block from either representation.
*
* The block opens with the RFC 3676 "-- " delimiter so receiving clients recognize it as a signature.
*/
data class SignatureBlock(val plain: String, val html: String) {
val isEmpty: Boolean get() = plain.isEmpty() && html.isEmpty()
companion object {
val EMPTY = SignatureBlock("", "")
/** The block for [signature], or [EMPTY] when there is none / it is blank. */
fun of(signature: Signature?): SignatureBlock {
if (signature == null) return EMPTY
val plainSig = signature.plainText().trimEnd()
if (plainSig.isBlank() && signature.html.isBlank()) return EMPTY
return SignatureBlock(
plain = "$DELIMITER_PLAIN$plainSig",
html = RichTextHtml.toHtml(RichTextContent(DELIMITER_PLAIN)) + signature.html,
)
}
private const val DELIMITER_PLAIN = "\n\n-- \n"
}
}
@@ -0,0 +1,27 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.data
import org.libremail.mail.HtmlToText
/**
* Derives the one-line mailbox preview snippet that is persisted alongside a fetched body.
*
* HTML bodies are reduced to readable text via [HtmlToText] (script/style *content* dropped, tags
* stripped, entities decoded) before the whitespace collapsing. Plain-text bodies get no markup
* handling at all — literal `<`/`>` characters survive — only whitespace collapsing. Both paths end
* with the same [MAX_LENGTH] cap.
*
* Derivation runs once, when a body is fetched and cached (plus the one-off migration backfill) —
* never per mailbox-list row.
*/
object Snippet {
const val MAX_LENGTH = 140
private val WHITESPACE = Regex("\\s+")
fun of(body: String, isHtml: Boolean): String {
val text = if (isHtml) HtmlToText.convert(body) else body
return text.replace(WHITESPACE, " ").trim().take(MAX_LENGTH)
}
}
@@ -0,0 +1,221 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.data.local
import android.content.Context
import android.util.Log
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.preferencesDataStore
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext
import net.zetetic.database.sqlcipher.SQLiteDatabase
import org.libremail.data.security.DatabaseKeyStore
import org.libremail.data.settings.SettingsRepository
import java.io.File
import javax.inject.Inject
import javax.inject.Singleton
private val Context.accountMigrationDataStore: DataStore<Preferences> by
preferencesDataStore(name = "libremail_account_migration")
/**
* One-time, crash-safe move of the account tables (`accounts`, `credentials`, `account_settings`,
* `signatures`) out of the auth-bound cache database [LibreMailDatabase] into the non-auth
* [AccountDatabase] (issue #111). Runs at startup, driven by `DatabaseModule.provideDatabase`, BEFORE
* Room opens the cache and its [MIGRATION_15_16] drops the moved tables.
*
* ### Why not a Room migration
* The copy is cross-database, so it needs `ATTACH DATABASE`, which SQLite forbids inside the
* transaction Room wraps every migration in. It therefore runs here on a dedicated SQLCipher
* connection before Room opens either database.
*
* ### Handling the encrypted source
* When the opt-in encrypted cache is on, the source `libremail.db` is SQLCipher-encrypted. The
* caller resolves and hands us its passphrase (the same one Room uses to open it); we attach the
* cache with that passphrase and copy into a plaintext `libremail-accounts.db`. When the cache is
* plaintext the passphrase is empty. Reading the source's schema validates the passphrase, so a
* genuinely wrong key fails loudly here (the same open would fail in Room) rather than losing data.
*
* The unrecoverable-key case does not reach us: `provideDatabase` wipes an undecryptable cache (and
* resets its seals) BEFORE calling us, so we then see a fresh/empty cache with nothing to move — the
* accounts trapped in that already-invalidated cache are lost regardless (the pre-existing bug), but
* no future invalidation can strand them again once they live in [AccountDatabase].
*
* ### Crash-safety & idempotency
* - We never drop the source here; [MIGRATION_15_16] does that after we return, so if we crash the
* source rows are still intact for the next attempt.
* - The copy uses `INSERT OR IGNORE`, so a re-run after a mid-copy crash converges (existing rows
* are skipped, never duplicated, and never overwrite anything the user changed post-migration).
* - The "done" flag is only set after a successful copy; until then every start retries. Once set we
* return immediately and never touch the cache passphrase again — so after migration the account
* database opens with no Keystore dependency at all.
*/
@Singleton
class AccountDataMigrator @Inject constructor(
@ApplicationContext private val context: Context,
private val keyStore: DatabaseKeyStore,
private val settingsRepository: SettingsRepository,
) {
/**
* Copy the account tables into [AccountDatabase] if it has not been done yet. Idempotent and
* safe to call from every `provideDatabase` construction. Throws (rather than silently skipping)
* on an unexpected copy failure so the caller does not proceed to drop the source tables — a
* crash-loop that preserves data is strictly safer than a wipe that loses it.
*/
suspend fun migrateIfNeeded() {
if (isDone()) return
val cacheFile = context.getDatabasePath(DatabaseFiles.NAME)
if (cacheFile.exists() && cacheFile.length() > 0L) {
// Read the cache in its CURRENT on-disk form. `provideDatabase` runs us before it converts
// between plaintext and encrypted, so the key is empty unless the file is encrypted now.
val cacheKey = if (DatabaseEncryption.isEncrypted(cacheFile)) {
keyStore.resolvePassphrase(settingsRepository.settings.first().appLock)
} else {
""
}
val accountsFile = context.getDatabasePath(DatabaseFiles.ACCOUNTS_NAME)
withContext(Dispatchers.IO) { copyAccountTables(cacheFile, cacheKey, accountsFile) }
}
markDone()
}
private suspend fun isDone(): Boolean = context.accountMigrationDataStore.data.first()[DONE] == true
private suspend fun markDone() {
context.accountMigrationDataStore.edit { it[DONE] = true }
}
companion object {
private const val TAG = "LibreMailAcctMigrate"
private val DONE = booleanPreferencesKey("accounts_moved_out_of_cache")
/** The account tables, parent before children so foreign keys never block an insert. */
private val TABLES = listOf("accounts", "credentials", "account_settings", "signatures")
/**
* DDL for the account tables in [AccountDatabase] v1, copied verbatim from the exported Room
* schema (`schemas/org.libremail.data.local.AccountDatabase/1.json`). It MUST stay byte-for-byte
* identical to what Room generates for those entities, or Room silently accepts a subtly wrong
* schema (its identity check only compares the hash it writes, not the pre-existing tables).
* `AccountDataMigratorTest.migratorDdlMatchesExportedAccountDatabaseSchema` guards it against the
* exported schema; `internal` only so that test can read it.
*/
internal val CREATE_TABLE_SQL = mapOf(
"accounts" to
"CREATE TABLE IF NOT EXISTS `accounts` (`id` TEXT NOT NULL, `email` TEXT NOT NULL, " +
"`displayName` TEXT NOT NULL, `authType` TEXT NOT NULL, `imap_host` TEXT NOT NULL, " +
"`imap_port` INTEGER NOT NULL, `imap_security` TEXT NOT NULL, `smtp_host` TEXT NOT NULL, " +
"`smtp_port` INTEGER NOT NULL, `smtp_security` TEXT NOT NULL, PRIMARY KEY(`id`))",
"credentials" to
"CREATE TABLE IF NOT EXISTS `credentials` (`accountId` TEXT NOT NULL, " +
"`encryptedSecret` TEXT NOT NULL, PRIMARY KEY(`accountId`))",
"account_settings" to
"CREATE TABLE IF NOT EXISTS `account_settings` (`accountId` TEXT NOT NULL, " +
"`signature` TEXT NOT NULL, `signatureEnabled` INTEGER NOT NULL, " +
"`notificationsEnabled` INTEGER NOT NULL, `retentionCount` INTEGER, " +
"`retentionMonths` INTEGER, PRIMARY KEY(`accountId`), " +
"FOREIGN KEY(`accountId`) REFERENCES `accounts`(`id`) " +
"ON UPDATE NO ACTION ON DELETE CASCADE )",
"signatures" to
"CREATE TABLE IF NOT EXISTS `signatures` (`id` TEXT NOT NULL, `accountId` TEXT NOT NULL, " +
"`name` TEXT NOT NULL, `contentHtml` TEXT NOT NULL, `isDefault` INTEGER NOT NULL, " +
"PRIMARY KEY(`id`), FOREIGN KEY(`accountId`) REFERENCES `accounts`(`id`) " +
"ON UPDATE NO ACTION ON DELETE CASCADE )",
)
internal const val SIGNATURES_INDEX_SQL =
"CREATE INDEX IF NOT EXISTS `index_signatures_accountId` ON `signatures` (`accountId`)"
/**
* Copies the account tables from [cacheFile] (opened with [cachePassphrase]; empty = plaintext)
* into a plaintext [accountsFile], creating the destination schema first. Opens the destination
* as `main` and attaches the (possibly encrypted) cache as `cache`, so a plaintext connection
* can still read the encrypted source via SQLCipher's per-attach key. Visible for the migrator
* test; call [migrateIfNeeded] in production.
*/
internal fun copyAccountTables(cacheFile: File, cachePassphrase: String, accountsFile: File) {
DatabaseEncryption.ensureNativeLibraryLoaded()
val db = SQLiteDatabase.openOrCreateDatabase(
accountsFile.absolutePath,
"".toByteArray(Charsets.US_ASCII), // destination is plaintext
null,
null,
)
try {
// No WAL: keep the destination in rollback-journal mode (as DatabaseEncryption does)
// so that after close there is no -wal/-shm holding uncommitted rows for Room to miss.
db.rawExecSQL("PRAGMA journal_mode = DELETE;")
val keyLiteral = cachePassphrase.replace("'", "''")
val cachePath = cacheFile.absolutePath.replace("'", "''")
db.rawExecSQL("ATTACH DATABASE '$cachePath' AS cache KEY '$keyLiteral';")
try {
val present = presentTables(db)
if (present.isEmpty()) return // fresh cache or already dropped: nothing to move
TABLES.forEach { db.rawExecSQL(CREATE_TABLE_SQL.getValue(it)) }
db.rawExecSQL(SIGNATURES_INDEX_SQL)
// Copy by explicit shared column names, never SELECT *: the on-disk cache may predate
// columns the current schema added (e.g. account_settings gained retentionCount /
// retentionMonths at v13), and a bare SELECT * would then supply fewer values than the
// destination has columns and fail the whole migration. Listing the columns the source
// actually has lets the destination's newer columns take their defaults (NULL). Parent
// first so an enforced foreign key would still be satisfied; INSERT OR IGNORE is idempotent.
TABLES.filter { it in present }.forEach { table ->
val cols = sharedColumns(db, table)
db.rawExecSQL("INSERT OR IGNORE INTO `$table` ($cols) SELECT $cols FROM cache.`$table`")
}
Log.d(TAG, "moved account tables into the account database: $present")
} finally {
db.rawExecSQL("DETACH DATABASE cache;")
}
} finally {
db.close()
}
// Room opens the destination next; drop any sidecars the copy left so a stale WAL/SHM can't
// confuse its first open.
val dir = accountsFile.parentFile
if (dir != null) {
listOf("-wal", "-shm", "-journal").forEach { File(dir, accountsFile.name + it).delete() }
}
}
private fun presentTables(db: SQLiteDatabase): Set<String> {
val names = TABLES.joinToString(",") { "'$it'" }
val present = mutableSetOf<String>()
db.rawQuery(
"SELECT name FROM cache.sqlite_master WHERE type = 'table' AND name IN ($names)",
null,
).use { cursor ->
while (cursor.moveToNext()) present += cursor.getString(0)
}
return present
}
/**
* Column names present in BOTH the freshly-created destination `$table` (always the current
* schema) and the source `cache.$table` (possibly an older on-disk schema), backtick-quoted and
* comma-joined for an INSERT/SELECT column list. Destination-only columns are omitted so they
* take their defaults instead of overflowing the value list.
*/
private fun sharedColumns(db: SQLiteDatabase, table: String): String {
val source = tableColumns(db, "cache", table)
return tableColumns(db, "main", table)
.filter { it in source }
.joinToString(", ") { "`$it`" }
}
/** The column names of `$schema.$table`, in declared order, via `PRAGMA table_info`. */
private fun tableColumns(db: SQLiteDatabase, schema: String, table: String): List<String> {
val columns = mutableListOf<String>()
db.rawQuery("PRAGMA $schema.table_info(`$table`)", null).use { cursor ->
val nameIndex = cursor.getColumnIndexOrThrow("name")
while (cursor.moveToNext()) columns += cursor.getString(nameIndex)
}
return columns
}
}
}
@@ -0,0 +1,51 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.data.local
import androidx.room.Database
import androidx.room.RoomDatabase
import org.libremail.data.local.dao.AccountDao
import org.libremail.data.local.dao.AccountSettingsDao
import org.libremail.data.local.dao.CredentialDao
import org.libremail.data.local.dao.SignatureDao
import org.libremail.data.local.entity.AccountEntity
import org.libremail.data.local.entity.AccountSettingsEntity
import org.libremail.data.local.entity.CredentialEntity
import org.libremail.data.local.entity.SignatureEntity
/**
* Durable store for the pieces of an account that must survive a mail-cache wipe (issue #111): the
* account itself, its sealed credential, per-account settings, and saved signatures.
*
* This lives in its OWN database file ([DatabaseFiles.ACCOUNTS_NAME]) that is deliberately NEVER
* bound to the auth-bound SQLCipher key. When app-lock + encrypted-cache are on and that key is
* invalidated (a genuine biometric re-enrollment or lock removal/re-add), only the mail cache
* ([LibreMailDatabase]) becomes undecryptable and is wiped; this database is untouched, so the user
* stays signed in instead of being dropped back into onboarding.
*
* It is plaintext on disk. The only secret it holds is [CredentialEntity.encryptedSecret], which is
* already AES-GCM ciphertext sealed at the column level by the non-auth
* [org.libremail.data.security.KeystoreCrypto] master key (and that key survives an auth-key
* invalidation), so the secret never touches disk in the clear regardless of this file's own
* encryption. Account metadata (email address, server hosts) is not a secret. Keeping the file
* plaintext is what makes it maximally resilient — it can always be opened without any Keystore key,
* so no key invalidation can ever strand it.
*
* Existing installs are migrated into this database once, at startup, by [AccountDataMigrator]
* before [MIGRATION_15_16] drops the moved tables from the cache database.
*/
@Database(
entities = [
AccountEntity::class,
CredentialEntity::class,
AccountSettingsEntity::class,
SignatureEntity::class,
],
version = 1,
exportSchema = true,
)
abstract class AccountDatabase : RoomDatabase() {
abstract fun accountDao(): AccountDao
abstract fun credentialDao(): CredentialDao
abstract fun accountSettingsDao(): AccountSettingsDao
abstract fun signatureDao(): SignatureDao
}
@@ -2,8 +2,8 @@
package org.libremail.data.local
import android.util.Log
import java.io.File
import net.zetetic.database.sqlcipher.SQLiteDatabase
import java.io.File
/**
* Converts the Room database file between plaintext and SQLCipher-encrypted form, in place and
@@ -40,7 +40,7 @@ object DatabaseEncryption {
* tables but not that pragma, and a reset version would make Room attempt a bogus migration.
*/
private fun migrate(dbFile: File, sourcePassphrase: String, targetPassphrase: String) {
ensureLibraryLoaded()
ensureNativeLibraryLoaded()
val dir = dbFile.parentFile ?: error("database file has no parent directory")
val tmp = File(dir, dbFile.name + ".migrate").apply { delete() }
@@ -98,7 +98,13 @@ object DatabaseEncryption {
}
@Volatile private var libraryLoaded = false
private fun ensureLibraryLoaded() {
/**
* Load SQLCipher's native library once. Public so other startup helpers that open a database via
* [net.zetetic.database.sqlcipher.SQLiteDatabase] before Room does (e.g. [AccountDataMigrator])
* can guarantee it is loaded first.
*/
fun ensureNativeLibraryLoaded() {
if (libraryLoaded) return
synchronized(this) {
if (!libraryLoaded) {
@@ -0,0 +1,42 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.data.local
import android.content.Context
/** Central names and wipe helper for the Room database files (and their SQLite sidecars). */
object DatabaseFiles {
const val NAME = "libremail.db"
/**
* The [org.libremail.data.local.AccountDatabase] file — accounts, credentials, per-account
* settings and signatures. Deliberately a DIFFERENT file from [NAME] and NEVER wiped by [clear],
* so a cache-key invalidation keeps the user signed in (issue #111).
*/
const val ACCOUNTS_NAME = "libremail-accounts.db"
/**
* The statically-nameable SQLite sidecars that accompany a database file. A transient `-mj*`
* master journal can also exist, but its suffix is random and so can't be listed by name —
* [clear] leans on [Context.deleteDatabase] to sweep that one up.
*/
private val SIDECAR_SUFFIXES = listOf("-wal", "-shm", "-journal")
/**
* [name] plus each of its statically-nameable sidecars. The single source of truth for which
* on-disk files make up a database file; `BackupPolicy` derives its never-back-up set from this
* so a new database (or a new sidecar suffix) can never silently fall out of the exclusions.
*/
fun fileNames(name: String): List<String> = listOf(name) + SIDECAR_SUFFIXES.map { name + it }
/**
* Delete the cache database ([NAME]) and every sidecar — including the `-mj*` master journal a
* hand-rolled suffix list would miss — via [Context.deleteDatabase]. NEVER touches
* [ACCOUNTS_NAME], so a cache-key invalidation keeps the user signed in (issue #111). Call only
* when no connection is open — used by the "clear + re-sync" path when the encryption key is
* invalidated and the encrypted database can no longer be decrypted.
*/
fun clear(context: Context) {
context.deleteDatabase(NAME)
}
}
@@ -0,0 +1,136 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.data.local
import android.content.Context
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import org.libremail.data.security.DatabaseKeyStore
import org.libremail.data.settings.SettingsRepository
import javax.inject.Inject
import javax.inject.Singleton
/** How the cache database ([LibreMailDatabase]) must be opened, decided by [DatabaseProvisioner]. */
sealed interface CacheOpenMode {
/** Open with SQLCipher, keyed by [passphrase] — the opt-in encrypted cache. */
data class Encrypted(val passphrase: String) : CacheOpenMode
/** Open with the default framework helper — the cache is plaintext on disk. */
data object Plaintext : CacheOpenMode
}
/**
* Runs the one-time, blocking startup sequence that must complete BEFORE Room opens either database —
* exactly once, memoized, and OFF the Hilt injection path (issue #93).
*
* `DatabaseModule.provideDatabase` used to do this work inline, with `runBlocking`, while Hilt
* constructed the singleton [LibreMailDatabase]: a DataStore read, a Keystore op, a possible SQLCipher
* re-key conversion, and (since #111) the cross-database [AccountDataMigrator]. All of it ran
* synchronously on whichever thread first injected the database — which can be the main thread — so the
* first DB access could jank or ANR (worst with the encrypted cache on). This class moves that work
* behind [prepareCache]; the Hilt providers wire it into a [DeferredOpenHelperFactory] so it runs
* lazily, on Room's background open, never at inject time.
*
* The sequence, its ordering, and its crash-safety are unchanged from the old `provideDatabase` — only
* WHERE and WHEN it runs moved:
* 1. If a screen-lock change flagged the encrypted cache for wiping, wipe it and reset its seals
* (before Room opens the file, so no open connection is deleted underneath it).
* 2. Run [AccountDataMigrator] — the one-time move of accounts/credentials/settings/signatures into
* the non-auth [AccountDatabase] (issue #111). MUST precede opening the cache (whose
* [MIGRATION_15_16] drops the moved tables) AND opening [AccountDatabase] (which reads the copied
* rows). Both databases' open paths gate on [prepareCache], so the migrate-before-open guarantee
* holds regardless of which database Room opens first.
* 3. Resolve the encryption gate: convert the on-disk cache to the form the `encryptCache` setting
* asks for, and report how the cache must be opened.
*
* [prepareCache] is memoized on success and guarded by a [Mutex], so the first database to open runs
* the sequence and any concurrent or later opener awaits the same result. A failure is NOT memoized, so
* it retries on the next open — preserving the migrator's "crash-loop rather than lose data" contract
* (a throw here means the cache never opens, so [MIGRATION_15_16] never drops the not-yet-copied rows).
*/
@Singleton
class DatabaseProvisioner internal constructor(
private val context: Context,
private val keyStore: DatabaseKeyStore,
private val settingsRepository: SettingsRepository,
private val accountDataMigrator: AccountDataMigrator,
private val ioDispatcher: CoroutineDispatcher,
) {
@Inject
constructor(
@ApplicationContext context: Context,
keyStore: DatabaseKeyStore,
settingsRepository: SettingsRepository,
accountDataMigrator: AccountDataMigrator,
) : this(context, keyStore, settingsRepository, accountDataMigrator, Dispatchers.IO)
private val mutex = Mutex()
@Volatile
private var prepared: CacheOpenMode? = null
/**
* Runs the startup sequence exactly once (on [ioDispatcher]) and returns how the cache must be
* opened. Idempotent and safe to call concurrently from both databases' open paths; the blocking
* work runs on [ioDispatcher], never on the caller's thread past the suspension point.
*/
suspend fun prepareCache(): CacheOpenMode {
prepared?.let { return it }
return mutex.withLock {
prepared ?: withContext(ioDispatcher) { runStartupSequence() }.also { prepared = it }
}
}
private suspend fun runStartupSequence(): CacheOpenMode {
val dbFile = context.getDatabasePath(DatabaseFiles.NAME)
// A screen-lock change (biometric re-enrollment / lock removal) can invalidate the auth-bound
// key so the encrypted cache is no longer decryptable. AppLockViewModel records that and
// restarts the app; we wipe the cache HERE — before Room opens it — so the file is never
// deleted from under an open connection. Crash-safe order: wipe + reset the seals, and only THEN
// clear the flag, so a kill mid-wipe just repeats the idempotent wipe next start. Only
// libremail.db is wiped: accounts/credentials live in AccountDatabase (a separate file), so the
// user stays signed in across the wipe (issue #111).
if (keyStore.isClearPending()) {
DatabaseFiles.clear(context)
keyStore.resetSealedPassphrase()
keyStore.clearClearPending()
}
// One-time move of accounts/credentials/settings/signatures into the non-auth AccountDatabase
// (issue #111). MUST run before the cache opens: opening it applies MIGRATION_15_16, which drops
// the moved tables. Runs AFTER the wipe above so an unrecoverable-key cache is gone first
// (nothing left to move) and we never block waiting on a passphrase we can't get.
accountDataMigrator.migrateIfNeeded()
// Opt-in at-rest encryption of the local cache (off by default). The conversion runs here —
// before the database is opened — so it never races an open connection; toggling the setting
// therefore takes effect on the next app start. The passphrase source is resolved from which
// seal actually exists (DatabaseKeyStore.resolvePassphrase), NOT from the app-lock setting (a
// separate DataStore that can disagree). When app-lock is ON the sealing key is auth-bound, so
// resolvePassphrase waits on PassphraseSession until the user authenticates — which is why this
// must never run on the main thread while the cache is locked (issue #93).
val settings = settingsRepository.settings.first()
val appLock = settings.appLock
return when {
settings.encryptCache -> {
val passphrase = keyStore.resolvePassphrase(appLock)
DatabaseEncryption.ensureEncrypted(dbFile, passphrase)
CacheOpenMode.Encrypted(passphrase)
}
DatabaseEncryption.isEncrypted(dbFile) -> {
// Encryption was turned back off — decrypt so the default (unkeyed) open succeeds.
val passphrase = keyStore.resolvePassphrase(appLock)
DatabaseEncryption.ensurePlaintext(dbFile, passphrase)
CacheOpenMode.Plaintext
}
else -> CacheOpenMode.Plaintext
}
}
}
@@ -0,0 +1,75 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.data.local
import androidx.sqlite.db.SupportSQLiteDatabase
import androidx.sqlite.db.SupportSQLiteOpenHelper
/**
* A [SupportSQLiteOpenHelper.Factory] that defers building the REAL open helper — and any blocking work
* that choosing and creating it entails — from Room's build/inject path to the FIRST actual database
* open (issue #93).
*
* Room calls [create] and [SupportSQLiteOpenHelper.setWriteAheadLoggingEnabled] while it builds the
* database, on whichever thread injected it (possibly the main thread); neither may block. This factory
* hands back a thin handle whose delegate is materialised only when the database is first opened
* (`writableDatabase` / `readableDatabase`), which Room performs on its background query executor. The
* [buildDelegate] lambda is where the caller runs the startup gate (see [DatabaseProvisioner]) and
* picks the concrete factory — so all of that runs off the injection path and off the main thread.
*/
internal class DeferredOpenHelperFactory(
private val buildDelegate: (SupportSQLiteOpenHelper.Configuration) -> SupportSQLiteOpenHelper,
) : SupportSQLiteOpenHelper.Factory {
override fun create(configuration: SupportSQLiteOpenHelper.Configuration): SupportSQLiteOpenHelper =
DeferredOpenHelper(configuration, buildDelegate)
}
/**
* The lazy handle returned by [DeferredOpenHelperFactory]. Everything Room touches before the first
* open is cheap; [buildDelegate] (which does the blocking work) runs only when [writableDatabase] or
* [readableDatabase] is first read.
*/
private class DeferredOpenHelper(
private val configuration: SupportSQLiteOpenHelper.Configuration,
private val buildDelegate: (SupportSQLiteOpenHelper.Configuration) -> SupportSQLiteOpenHelper,
) : SupportSQLiteOpenHelper {
private val lock = Any()
/** Guarded by [lock]. Null until the database is first opened — `create()` must stay non-blocking. */
private var delegate: SupportSQLiteOpenHelper? = null
/**
* Guarded by [lock]. Room may set WAL before the first open; we remember the value and apply it when
* the delegate is built, rather than building the delegate early (which would run the gate at inject
* time). Null means "Room never asked", so the delegate keeps the real factory's own default.
*/
private var writeAheadLoggingEnabled: Boolean? = null
override val databaseName: String?
get() = configuration.name
override fun setWriteAheadLoggingEnabled(enabled: Boolean) {
synchronized(lock) {
writeAheadLoggingEnabled = enabled
delegate?.setWriteAheadLoggingEnabled(enabled)
}
}
override val writableDatabase: SupportSQLiteDatabase
get() = delegate().writableDatabase
override val readableDatabase: SupportSQLiteDatabase
get() = delegate().readableDatabase
override fun close() {
// Never opened means nothing to close; do NOT build the delegate just to close it.
synchronized(lock) { delegate?.close() }
}
private fun delegate(): SupportSQLiteOpenHelper = synchronized(lock) {
delegate ?: buildDelegate(configuration).also { built ->
writeAheadLoggingEnabled?.let(built::setWriteAheadLoggingEnabled)
delegate = built
}
}
}
@@ -3,36 +3,46 @@ package org.libremail.data.local
import androidx.room.Database
import androidx.room.RoomDatabase
import org.libremail.data.local.dao.AccountDao
import org.libremail.data.local.dao.AttachmentDao
import org.libremail.data.local.dao.CredentialDao
import org.libremail.data.local.dao.BackfillProgressDao
import org.libremail.data.local.dao.DraftDao
import org.libremail.data.local.dao.FolderDao
import org.libremail.data.local.dao.MessageDao
import org.libremail.data.local.dao.OutboxDao
import org.libremail.data.local.entity.AccountEntity
import org.libremail.data.local.entity.AttachmentEntity
import org.libremail.data.local.entity.CredentialEntity
import org.libremail.data.local.entity.BackfillProgressEntity
import org.libremail.data.local.entity.DraftEntity
import org.libremail.data.local.entity.FolderEntity
import org.libremail.data.local.entity.MessageEntity
import org.libremail.data.local.entity.OutboxEntity
/**
* The offline mail cache. Everything here is re-derivable from the server on a fresh sync, so it is
* the database that opt-in SQLCipher encryption is applied to and — when the auth-bound key is
* invalidated — the one that "clear + re-sync" wipes.
*
* Account identity and user configuration (accounts, credentials, per-account settings, signatures)
* are deliberately NOT here: they live in [AccountDatabase], a separate non-auth-bound file, so a
* cache-key invalidation can never sign the user out (issue #111). [MIGRATION_15_16] dropped those
* tables from this database; [AccountDataMigrator] copies existing rows into [AccountDatabase] first.
*/
@Database(
entities = [
AccountEntity::class,
MessageEntity::class,
CredentialEntity::class,
AttachmentEntity::class,
OutboxEntity::class,
DraftEntity::class,
FolderEntity::class,
BackfillProgressEntity::class,
],
version = 7,
version = 18,
exportSchema = true,
)
abstract class LibreMailDatabase : RoomDatabase() {
abstract fun messageDao(): MessageDao
abstract fun accountDao(): AccountDao
abstract fun credentialDao(): CredentialDao
abstract fun attachmentDao(): AttachmentDao
abstract fun outboxDao(): OutboxDao
abstract fun draftDao(): DraftDao
abstract fun folderDao(): FolderDao
abstract fun backfillProgressDao(): BackfillProgressDao
}
@@ -1,26 +1,35 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.data.local
import org.libremail.data.local.entity.AccountEntity
import org.libremail.data.local.entity.AttachmentEntity
import org.libremail.data.local.entity.DraftEntity
import org.libremail.data.local.entity.MessageEntity
import org.libremail.data.local.entity.OutboxEntity
import org.libremail.data.local.entity.ServerConfigEmbedded
import org.json.JSONArray
import org.json.JSONObject
import org.libremail.data.local.entity.AccountEntity
import org.libremail.data.local.entity.AccountSettingsEntity
import org.libremail.data.local.entity.AttachmentEntity
import org.libremail.data.local.entity.DraftEntity
import org.libremail.data.local.entity.FolderEntity
import org.libremail.data.local.entity.FolderUnreadCount
import org.libremail.data.local.entity.MessageEntity
import org.libremail.data.local.entity.MessageSummary
import org.libremail.data.local.entity.OutboxEntity
import org.libremail.data.local.entity.ServerConfigEmbedded
import org.libremail.domain.model.Account
import org.libremail.domain.model.AccountSettings
import org.libremail.domain.model.Attachment
import org.libremail.domain.model.Draft
import org.libremail.domain.model.OutboxMessage
import org.libremail.domain.model.AuthType
import org.libremail.domain.model.Draft
import org.libremail.domain.model.Folder
import org.libremail.domain.model.FolderRole
import org.libremail.domain.model.ImapConnectionParams
import org.libremail.domain.model.MailSecurity
import org.libremail.domain.model.Message
import org.libremail.domain.model.OutboxMessage
import org.libremail.domain.model.OutgoingAttachment
import org.libremail.domain.model.ServerConfig
import org.libremail.domain.model.SmtpParams
import org.libremail.domain.model.UnreadCount
import org.libremail.mail.AttachmentPart
import org.libremail.mail.FetchedFolder
import org.libremail.mail.FetchedMessage
internal fun AccountEntity.toDomain(): Account = Account(
@@ -41,26 +50,39 @@ internal fun Account.toEntity(): AccountEntity = AccountEntity(
smtp = ServerConfigEmbedded(smtp.host, smtp.port, smtp.security.name),
)
internal fun AccountSettingsEntity.toDomain(): AccountSettings = AccountSettings(
accountId = accountId,
signature = signature,
signatureEnabled = signatureEnabled,
notificationsEnabled = notificationsEnabled,
retentionCount = retentionCount,
retentionMonths = retentionMonths,
)
internal fun AccountSettings.toEntity(): AccountSettingsEntity = AccountSettingsEntity(
accountId = accountId,
signature = signature,
signatureEnabled = signatureEnabled,
notificationsEnabled = notificationsEnabled,
retentionCount = retentionCount,
retentionMonths = retentionMonths,
)
internal fun Account.toImapParams(
secret: String,
useXoauth2: Boolean,
strictStartTls: Boolean = true,
): ImapConnectionParams =
ImapConnectionParams(
host = imap.host,
port = imap.port,
security = imap.security,
username = email,
secret = secret,
useXoauth2 = useXoauth2,
strictStartTls = strictStartTls,
)
): ImapConnectionParams = ImapConnectionParams(
host = imap.host,
port = imap.port,
security = imap.security,
username = email,
secret = secret,
useXoauth2 = useXoauth2,
strictStartTls = strictStartTls,
)
internal fun Account.toSmtpParams(
secret: String,
useXoauth2: Boolean,
strictStartTls: Boolean = true,
): SmtpParams =
internal fun Account.toSmtpParams(secret: String, useXoauth2: Boolean, strictStartTls: Boolean = true): SmtpParams =
SmtpParams(
host = smtp.host,
port = smtp.port,
@@ -83,23 +105,77 @@ internal fun MessageEntity.toDomain(): Message = Message(
timestampMillis = timestampMillis,
isRead = isRead,
isStarred = isStarred,
folder = folder,
inInbox = inInbox,
bodyFetched = bodyFetched,
)
internal fun FetchedMessage.toEntity(accountId: String, inInbox: Boolean = true): MessageEntity = MessageEntity(
id = "$accountId:$uid",
/**
* Maps a mailbox-list projection to the domain model. [Message.body]/[Message.isHtml] are left
* empty because the list never renders them — the reader loads the body on demand (see
* [MessageSummary]).
*/
internal fun MessageSummary.toDomain(): Message = Message(
id = id,
accountId = accountId,
sender = sender,
senderEmail = senderEmail,
subject = subject,
snippet = "",
snippet = snippet,
body = "",
isHtml = false,
timestampMillis = timestampMillis,
isRead = isRead,
isStarred = isFlagged,
isStarred = isStarred,
folder = folder,
inInbox = inInbox,
bodyFetched = false,
bodyFetched = bodyFetched,
)
internal fun FetchedMessage.toEntity(accountId: String, folder: String, inInbox: Boolean = true): MessageEntity =
MessageEntity(
id = "$accountId:$folder:$uid",
accountId = accountId,
sender = sender,
senderEmail = senderEmail,
subject = subject,
snippet = "",
body = "",
isHtml = false,
timestampMillis = timestampMillis,
isRead = isRead,
isStarred = isFlagged,
folder = folder,
inInbox = inInbox,
bodyFetched = false,
uid = uid.toLongOrNull() ?: 0L,
)
internal fun FolderEntity.toDomain(): Folder = Folder(
accountId = accountId,
fullName = fullName,
displayName = displayName,
role = runCatching { FolderRole.valueOf(role) }.getOrDefault(FolderRole.NORMAL),
selectable = selectable,
specialUse = specialUse,
hierarchyDelimiter = hierarchyDelimiter?.firstOrNull(),
)
internal fun FetchedFolder.toEntity(accountId: String, sortOrder: Int): FolderEntity = FolderEntity(
accountId = accountId,
fullName = fullName,
displayName = displayName,
role = FolderRole.roleOf(fullName, displayName, attributes).name,
selectable = selectable,
sortOrder = sortOrder,
specialUse = FolderRole.isServerSpecial(attributes),
hierarchyDelimiter = hierarchyDelimiter?.toString(),
)
internal fun FolderUnreadCount.toDomain(): UnreadCount = UnreadCount(
accountId = accountId,
folder = folder,
count = unreadCount,
)
internal fun AttachmentEntity.toDomain(): Attachment = Attachment(
@@ -108,6 +184,7 @@ internal fun AttachmentEntity.toDomain(): Attachment = Attachment(
filename = filename,
mimeType = mimeType,
sizeBytes = sizeBytes,
contentId = contentId,
)
internal fun AttachmentPart.toEntity(messageId: String): AttachmentEntity = AttachmentEntity(
@@ -116,6 +193,7 @@ internal fun AttachmentPart.toEntity(messageId: String): AttachmentEntity = Atta
filename = filename,
mimeType = mimeType,
sizeBytes = sizeBytes,
contentId = contentId,
)
internal fun DraftEntity.toDomain(): Draft = Draft(
@@ -123,9 +201,11 @@ internal fun DraftEntity.toDomain(): Draft = Draft(
accountId = accountId,
to = toAddresses,
cc = ccAddresses,
bcc = bccAddresses,
subject = subject,
body = body,
updatedAt = updatedAt,
bodyHtml = bodyHtml,
attachments = attachments.toOutgoingAttachments(),
)
@@ -134,27 +214,45 @@ internal fun Draft.toEntity(): DraftEntity = DraftEntity(
accountId = accountId,
toAddresses = to,
ccAddresses = cc,
bccAddresses = bcc,
subject = subject,
body = body,
updatedAt = updatedAt,
attachments = attachments.toJson(),
attachments = attachments.toOutgoingAttachmentsJson(),
bodyHtml = bodyHtml,
)
/** Serializes draft attachments as a JSON array of {uri, name} objects ("" when empty). */
private fun List<OutgoingAttachment>.toJson(): String {
/**
* Serializes outgoing attachments as a JSON array of `{uri, name, contentId?, isInline?}` objects
* ("" when empty). Shared by the drafts column and the outbox column, so an inline image's
* cid↔file pairing survives a draft save/reopen and a queued send alike. The two optional keys are
* omitted for a plain attachment, so an old draft (written before inline images) reads back with
* `contentId = null` / `isInline = false`.
*/
internal fun List<OutgoingAttachment>.toOutgoingAttachmentsJson(): String {
if (isEmpty()) return ""
val array = JSONArray()
forEach { array.put(JSONObject().put("uri", it.uri).put("name", it.name)) }
forEach { attachment ->
val obj = JSONObject().put("uri", attachment.uri).put("name", attachment.name)
attachment.contentId?.let { obj.put("contentId", it) }
if (attachment.isInline) obj.put("isInline", true)
array.put(obj)
}
return array.toString()
}
private fun String.toOutgoingAttachments(): List<OutgoingAttachment> {
internal fun String.toOutgoingAttachments(): List<OutgoingAttachment> {
if (isBlank()) return emptyList()
return runCatching {
val array = JSONArray(this)
(0 until array.length()).map { i ->
val obj = array.getJSONObject(i)
OutgoingAttachment(obj.getString("uri"), obj.optString("name"))
OutgoingAttachment(
uri = obj.getString("uri"),
name = obj.optString("name"),
contentId = obj.optString("contentId").takeIf { it.isNotEmpty() },
isInline = obj.optBoolean("isInline", false),
)
}
}.getOrDefault(emptyList())
}
@@ -166,7 +264,9 @@ internal fun OutboxEntity.toDomain(): OutboxMessage = OutboxMessage(
body = body,
createdAt = createdAt,
lastError = lastError,
bodyHtml = bodyHtml,
)
private fun String.toMailSecurity(): MailSecurity =
runCatching { MailSecurity.valueOf(this) }.getOrDefault(MailSecurity.SSL_TLS)
private fun String.toMailSecurity(): MailSecurity = runCatching {
MailSecurity.valueOf(this)
}.getOrDefault(MailSecurity.SSL_TLS)
@@ -3,6 +3,7 @@ package org.libremail.data.local
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import org.libremail.data.Snippet
/** v1 -> v2: add the encrypted-credentials table (preserves existing accounts/messages). */
val MIGRATION_1_2 = object : Migration(1, 2) {
@@ -127,3 +128,234 @@ val MIGRATION_6_7 = object : Migration(6, 7) {
db.execSQL("ALTER TABLE `drafts_new` RENAME TO `drafts`")
}
}
/**
* v7 -> v8: folder-aware mail (preserves existing data).
* - `messages`: add a `folder` column (existing rows are inbox rows). The first post-upgrade INBOX
* sync reconciles ids, which now embed the folder ("accountId:folder:uid").
* - add the `folders` table caching each account's IMAP folder list for the navigation drawer.
*/
val MIGRATION_7_8 = object : Migration(7, 8) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE `messages` ADD COLUMN `folder` TEXT NOT NULL DEFAULT 'INBOX'")
db.execSQL(
"CREATE TABLE IF NOT EXISTS `folders` (" +
"`accountId` TEXT NOT NULL, `fullName` TEXT NOT NULL, `displayName` TEXT NOT NULL, " +
"`role` TEXT NOT NULL, `selectable` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL, " +
"PRIMARY KEY(`accountId`, `fullName`))",
)
}
}
/**
* v8 -> v9: per-account settings (preserves existing data). Adds the `account_settings` table with a
* cascading foreign key to `accounts`, and backfills a default row for every existing account.
*
* Columns are declared without SQL DEFAULTs and the backfill lists every column explicitly (the
* MIGRATION_4_5/5_6 pattern), so the fresh-install schema matches the migrated one — avoiding the
* `@ColumnInfo(defaultValue)` mismatch that MIGRATION_6_7 had to repair.
*/
val MIGRATION_8_9 = object : Migration(8, 9) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL(
"CREATE TABLE IF NOT EXISTS `account_settings` (" +
"`accountId` TEXT NOT NULL, `signature` TEXT NOT NULL, " +
"`signatureEnabled` INTEGER NOT NULL, `notificationsEnabled` INTEGER NOT NULL, " +
"PRIMARY KEY(`accountId`), " +
"FOREIGN KEY(`accountId`) REFERENCES `accounts`(`id`) " +
"ON UPDATE NO ACTION ON DELETE CASCADE)",
)
db.execSQL(
"INSERT INTO `account_settings` " +
"(`accountId`, `signature`, `signatureEnabled`, `notificationsEnabled`) " +
"SELECT `id`, '', 1, 1 FROM `accounts`",
)
}
}
/**
* v9 -> v10: bcc support for outgoing mail (preserves existing data). Adds a `bccAddresses` column
* to `outbox` and `drafts` so a `mailto:`-launched (or manually addressed) blind-copy recipient
* survives being queued and saved. The `DEFAULT ''` matches the entities' `@ColumnInfo(defaultValue)`
* so the fresh-install schema validates identically to the migrated one (the MIGRATION_7_8 pattern).
*/
val MIGRATION_9_10 = object : Migration(9, 10) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE `outbox` ADD COLUMN `bccAddresses` TEXT NOT NULL DEFAULT ''")
db.execSQL("ALTER TABLE `drafts` ADD COLUMN `bccAddresses` TEXT NOT NULL DEFAULT ''")
}
}
/**
* v10 -> v11: rich composition (preserves existing data).
* - `drafts`/`outbox`: add a nullable `bodyHtml` column carrying the HTML form of the body when a
* message was composed with formatting (null = plaintext-only, sent/kept exactly as before).
* - add the `signatures` table (multiple named signatures per account, one default), with a
* cascading foreign key to `accounts`, and backfill each account's existing per-account settings
* signature as its default signature so nobody loses one on upgrade.
*/
val MIGRATION_10_11 = object : Migration(10, 11) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE `drafts` ADD COLUMN `bodyHtml` TEXT")
db.execSQL("ALTER TABLE `outbox` ADD COLUMN `bodyHtml` TEXT")
db.execSQL(
"CREATE TABLE IF NOT EXISTS `signatures` (" +
"`id` TEXT NOT NULL, `accountId` TEXT NOT NULL, `name` TEXT NOT NULL, " +
"`contentHtml` TEXT NOT NULL, `isDefault` INTEGER NOT NULL, PRIMARY KEY(`id`), " +
"FOREIGN KEY(`accountId`) REFERENCES `accounts`(`id`) " +
"ON UPDATE NO ACTION ON DELETE CASCADE)",
)
db.execSQL("CREATE INDEX IF NOT EXISTS `index_signatures_accountId` ON `signatures` (`accountId`)")
// Preserve any existing plain-text per-account signature as that account's default signature.
// Newlines become <br> so the HTML keeps the original line breaks; other characters are rare
// in signatures and pass through unescaped.
db.execSQL(
"INSERT INTO `signatures` (`id`, `accountId`, `name`, `contentHtml`, `isDefault`) " +
"SELECT `accountId` || ':default-signature', `accountId`, 'Signature', " +
"replace(`signature`, char(10), '<br>'), 1 " +
"FROM `account_settings` WHERE `signature` <> ''",
)
}
}
/**
* v11 -> v12: drawer folder de-duplication (preserves existing data). Adds a `specialUse` column to
* `folders` recording whether the server advertises the folder as special-use (RFC 6154), so the
* drawer can tell a provider's built-in folder from a same-named user folder. `DEFAULT 0` matches
* the entity's `@ColumnInfo(defaultValue = "0")` so fresh-install and migrated schemas validate
* identically (the MIGRATION_9_10 pattern); the next folder refresh backfills the real value.
*/
val MIGRATION_11_12 = object : Migration(11, 12) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE `folders` ADD COLUMN `specialUse` INTEGER NOT NULL DEFAULT 0")
}
}
/**
* v12 -> v13: full-history backfill + device-only retention (issues #12/#13; preserves existing data).
* - `messages`: add the materialized `uid` column (`DEFAULT 0`, matching the entity's
* `@ColumnInfo(defaultValue = "0")`) and backfill it from the numeric tail of the existing
* "accountId:folder:uid" id. `rtrim(id, '0123456789')` strips the trailing digits, leaving the
* prefix up to and including the final ':'; the remainder is the UID. Non-numeric tails cast to 0
* and are refreshed to the real UID on the next sync.
* - `account_settings`: add nullable `retentionCount` / `retentionMonths` overrides (NULL = inherit
* the global default), declared without SQL defaults to match the entity's nullable columns.
* - add the `backfill_progress` table tracking each folder's paging boundary so the backfill resumes
* after process death / network loss.
*/
val MIGRATION_12_13 = object : Migration(12, 13) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE `messages` ADD COLUMN `uid` INTEGER NOT NULL DEFAULT 0")
db.execSQL(
"UPDATE `messages` SET `uid` = " +
"CAST(substr(`id`, length(rtrim(`id`, '0123456789')) + 1) AS INTEGER)",
)
db.execSQL("ALTER TABLE `account_settings` ADD COLUMN `retentionCount` INTEGER")
db.execSQL("ALTER TABLE `account_settings` ADD COLUMN `retentionMonths` INTEGER")
db.execSQL(
"CREATE TABLE IF NOT EXISTS `backfill_progress` (" +
"`accountId` TEXT NOT NULL, `folder` TEXT NOT NULL, " +
"`nextBeforeUid` INTEGER NOT NULL, `complete` INTEGER NOT NULL, " +
"PRIMARY KEY(`accountId`, `folder`))",
)
// Index the folder-scoped UID probes the backfill/reconcile hot paths run on every page/sync.
db.execSQL(
"CREATE INDEX IF NOT EXISTS `index_messages_accountId_folder_uid` " +
"ON `messages` (`accountId`, `folder`, `uid`)",
)
}
}
/**
* v13 -> v14: data-only, no schema change (preserves existing data). Re-derives the persisted
* `snippet` of every message with a cached body using [Snippet.of], which — unlike the derivation
* it replaces — respects `isHtml`: HTML rows lose leaked `<style>`/`<script>` text and literal
* entities, plain-text rows get back any `<...>` text that was wrongly stripped as markup. A
* snippet is only derived when a body is first fetched, so without this pass existing rows would
* keep their broken snippets forever. Snippets are computed while the cursor streams (bodies are
* never all held in memory) and the small id→snippet batch is applied after it closes.
*/
val MIGRATION_13_14 = object : Migration(13, 14) {
override fun migrate(db: SupportSQLiteDatabase) {
val updates = mutableListOf<Pair<String, String>>()
db.query("SELECT `id`, `body`, `isHtml` FROM `messages` WHERE `bodyFetched` = 1").use { cursor ->
while (cursor.moveToNext()) {
val id = cursor.getString(0)
val snippet = Snippet.of(body = cursor.getString(1), isHtml = cursor.getInt(2) != 0)
updates += id to snippet
}
}
updates.forEach { (id, snippet) ->
db.execSQL("UPDATE `messages` SET `snippet` = ? WHERE `id` = ?", arrayOf(snippet, id))
}
}
}
/**
* v14 -> v15: persist the server-reported IMAP hierarchy delimiter (issue #66; preserves existing
* data). Adds a nullable `hierarchyDelimiter` column to `folders` recording the separator character
* the server reported for the folder in its LIST response (e.g. "/" for Gmail, "." for some servers),
* so the drawer splits a folder's parent on the authoritative delimiter instead of re-inferring it
* from the name. Nullable with no SQL default (the MIGRATION_10_11 `bodyHtml` pattern) so existing
* rows read back null and fall back to the legacy inference until the next folder refresh backfills
* the real delimiter (`FolderDao.replaceForAccount` re-inserts every folder on each sync).
*/
val MIGRATION_14_15 = object : Migration(14, 15) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE `folders` ADD COLUMN `hierarchyDelimiter` TEXT")
}
}
/**
* v15 -> v16: move account identity + configuration OUT of the cache database (issue #111). The
* `accounts`, `credentials`, `account_settings` and `signatures` tables now live in [AccountDatabase]
* — a separate file that is never sealed by the auth-bound SQLCipher key — so a cache-key invalidation
* (biometric re-enrollment / lock removal) wipes only mail and can no longer sign the user out.
*
* The rows are copied into [AccountDatabase] by [AccountDataMigrator] at startup BEFORE Room opens the
* cache and runs this migration. The copy CANNOT happen here: Room wraps each migration in a
* transaction and SQLite forbids `ATTACH DATABASE` inside one, so a cross-database copy has to run on
* a separate connection before the cache is opened. This migration therefore only drops the tables
* that were moved. `DROP TABLE IF EXISTS` keeps it idempotent, and children (foreign-keyed to
* `accounts`) are dropped before the parent so the drop never trips a foreign-key check.
*/
val MIGRATION_15_16 = object : Migration(15, 16) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("DROP TABLE IF EXISTS `signatures`")
db.execSQL("DROP TABLE IF EXISTS `account_settings`")
db.execSQL("DROP TABLE IF EXISTS `credentials`")
db.execSQL("DROP TABLE IF EXISTS `accounts`")
}
}
/**
* v16 -> v17: inline-image support in the reader (issue #133; preserves existing data). Adds a
* nullable `contentId` column to `attachments` recording the `Content-ID` of an inline image
* (`<img src="cid:...">`) so the reader's WebView can resolve `cid:` requests to the cached bytes,
* and so such parts can be filtered out of the user-facing attachment list. Nullable with no SQL
* default (the MIGRATION_14_15 `hierarchyDelimiter` pattern) so existing attachment rows read back
* null — i.e. treated as ordinary attachments — until the next fetch reclassifies them.
*/
val MIGRATION_16_17 = object : Migration(16, 17) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE `attachments` ADD COLUMN `contentId` TEXT")
}
}
/**
* v17 -> v18: inline-image support for outgoing mail (issue #77; preserves existing data). Adds an
* `attachments` column to `outbox` holding JSON metadata (`{uri, name, contentId?, isInline?}`, one
* entry per staged file in index order) so the send worker can pair an inline image's `Content-ID`
* with its staged file. `DEFAULT ''` matches the entity's `@ColumnInfo(defaultValue = "")` so the
* fresh-install schema validates identically to the migrated one (the MIGRATION_9_10 `bccAddresses`
* pattern); a message queued before the upgrade reads back "" and its staged files are still sent as
* plain attachments (the send worker's positional fallback).
*/
val MIGRATION_17_18 = object : Migration(17, 18) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE `outbox` ADD COLUMN `attachments` TEXT NOT NULL DEFAULT ''")
}
}
@@ -0,0 +1,21 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.data.local.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import kotlinx.coroutines.flow.Flow
import org.libremail.data.local.entity.AccountSettingsEntity
@Dao
interface AccountSettingsDao {
@Query("SELECT * FROM account_settings WHERE accountId = :accountId LIMIT 1")
fun observe(accountId: String): Flow<AccountSettingsEntity?>
@Query("SELECT * FROM account_settings WHERE accountId = :accountId LIMIT 1")
suspend fun get(accountId: String): AccountSettingsEntity?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(settings: AccountSettingsEntity)
}
@@ -11,9 +11,21 @@ import org.libremail.data.local.entity.AttachmentEntity
@Dao
interface AttachmentDao {
@Query("SELECT * FROM attachments WHERE messageId = :messageId ORDER BY partIndex")
/**
* The message's user-facing attachments for the reader's attachment list. Inline images
* (`contentId IS NOT NULL`) are excluded — they render in the body via `cid:`, not as downloads
* (issue #133).
*/
@Query("SELECT * FROM attachments WHERE messageId = :messageId AND contentId IS NULL ORDER BY partIndex")
fun observeForMessage(messageId: String): Flow<List<AttachmentEntity>>
/**
* One-shot read of ALL of a message's cached parts — attachments AND inline images — e.g. to
* pre-download their bytes or resolve a `cid:` reference. The reader filters by [AttachmentEntity.contentId].
*/
@Query("SELECT * FROM attachments WHERE messageId = :messageId ORDER BY partIndex")
suspend fun getForMessage(messageId: String): List<AttachmentEntity>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(attachments: List<AttachmentEntity>)
@@ -0,0 +1,24 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.data.local.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import org.libremail.data.local.entity.BackfillProgressEntity
@Dao
interface BackfillProgressDao {
@Query("SELECT * FROM backfill_progress WHERE accountId = :accountId AND folder = :folder LIMIT 1")
suspend fun get(accountId: String, folder: String): BackfillProgressEntity?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(progress: BackfillProgressEntity)
@Query("DELETE FROM backfill_progress WHERE accountId = :accountId")
suspend fun deleteForAccount(accountId: String)
/** Clears all backfill progress (e.g. when the global retention default changes) so it re-evaluates. */
@Query("DELETE FROM backfill_progress")
suspend fun deleteAll()
}
@@ -0,0 +1,33 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.data.local.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Transaction
import kotlinx.coroutines.flow.Flow
import org.libremail.data.local.entity.FolderEntity
@Dao
interface FolderDao {
@Query("SELECT * FROM folders WHERE accountId = :accountId ORDER BY sortOrder ASC")
fun observeForAccount(accountId: String): Flow<List<FolderEntity>>
/** One-shot read of an account's folders, e.g. to resolve a role folder for archive/spam/trash. */
@Query("SELECT * FROM folders WHERE accountId = :accountId ORDER BY sortOrder ASC")
suspend fun getForAccountOnce(accountId: String): List<FolderEntity>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAll(folders: List<FolderEntity>)
@Query("DELETE FROM folders WHERE accountId = :accountId")
suspend fun deleteForAccount(accountId: String)
/** Replaces an account's folder set with the freshly-listed one (delete-then-insert). */
@Transaction
suspend fun replaceForAccount(accountId: String, folders: List<FolderEntity>) {
deleteForAccount(accountId)
insertAll(folders)
}
}
@@ -1,37 +1,143 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.data.local.dao
import androidx.paging.PagingSource
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import kotlinx.coroutines.flow.Flow
import org.libremail.data.local.entity.FolderUnreadCount
import org.libremail.data.local.entity.MessageEntity
import org.libremail.data.local.entity.MessageRouting
import org.libremail.data.local.entity.MessageSummary
@Dao
interface MessageDao {
@Query("SELECT * FROM messages ORDER BY timestampMillis DESC")
fun observeAll(): Flow<List<MessageEntity>>
/**
* Mailbox-list projection ordered newest-first. Deliberately omits the large `body`/`isHtml`
* columns: the list observes every cached message at once, and pulling full bodies through
* SQLite's shared ~2 MB CursorWindow overflows it once enough large bodies are cached
* (issue #51). Bodies are loaded lazily per-message via [getById] when a message is opened.
*/
@Query(
"SELECT id, accountId, sender, senderEmail, subject, snippet, timestampMillis, " +
"isRead, isStarred, folder, inInbox, bodyFetched FROM messages ORDER BY timestampMillis DESC",
)
fun observeSummaries(): Flow<List<MessageSummary>>
/**
* Mailbox-list projection scoped in SQL to one account's [folder], newest-first. Unlike
* [observeSummaries] this pulls only that folder's rows and re-emits only when they actually
* change, so the mailbox list's cost scales with what's shown, not the whole cache (issue #86).
* `inInbox` is *not* filtered here so the one query serves both the normal list (caller keeps
* `inInbox = 1` rows) and search (caller keeps rows matching the query, including transient
* `inInbox = 0` server-search hits). Served by the existing `(accountId, folder, uid)` index (its
* `(accountId, folder)` prefix), so no new index — and no schema migration — is needed.
*/
@Query(
"SELECT id, accountId, sender, senderEmail, subject, snippet, timestampMillis, " +
"isRead, isStarred, folder, inInbox, bodyFetched FROM messages " +
"WHERE accountId = :accountId AND folder = :folder ORDER BY timestampMillis DESC",
)
fun observeFolderSummaries(accountId: String, folder: String): Flow<List<MessageSummary>>
/**
* Unified-inbox projection: [folder] across every account, newest-first. Scoped in SQL like
* [observeFolderSummaries] but without an account predicate (issue #86). No existing index leads
* with `folder`, so this still scans in timestamp order — far cheaper than [observeSummaries] (it
* materializes only this folder's rows, not the whole cache) but not O(1); a large multi-account
* unified inbox is a candidate for a `(folder, inInbox, timestampMillis)` index + paging.
*/
@Query(
"SELECT id, accountId, sender, senderEmail, subject, snippet, timestampMillis, " +
"isRead, isStarred, folder, inInbox, bodyFetched FROM messages " +
"WHERE folder = :folder ORDER BY timestampMillis DESC",
)
fun observeUnifiedFolderSummaries(folder: String): Flow<List<MessageSummary>>
/**
* Paged unified-inbox projection: folder-synced rows of [folder] across every account,
* newest-first, as a Paging 3 [PagingSource] (issue #124). Unlike [observeUnifiedFolderSummaries]
* — which materializes the *entire* unified inbox (~thousands of rows) on every emission — Room
* loads only the requested window (LIMIT/OFFSET), so the mailbox list's query, mapping, and
* recomposition cost scale with what's on screen, not the whole cache. Filters `inInbox = 1`
* because the paged browse list shows only synced rows; unified *search* (which must also surface
* transient `inInbox = 0` hits) stays on [observeUnifiedFolderSummaries]. Profiling (see
* `docs/perf/issue-124-unified-inbox-paging.md`) showed the first page loads flat regardless of
* total cache size on the existing indices, so no `(folder, …)` index / schema migration is added.
*/
@Query(
"SELECT id, accountId, sender, senderEmail, subject, snippet, timestampMillis, " +
"isRead, isStarred, folder, inInbox, bodyFetched FROM messages " +
"WHERE folder = :folder AND inInbox = 1 ORDER BY timestampMillis DESC",
)
fun pagingUnifiedFolderSummaries(folder: String): PagingSource<Int, MessageSummary>
/**
* Live per-(account, folder) unread counts for the drawer's folder badges and the bold styling of
* accounts with unread mail. Counts only folder-synced rows (`inInbox = 1`), so transient
* server-search hits never inflate a badge; read rows and folders with no unread mail are simply
* absent from the result. A pure `COUNT(*)` aggregate — no message rows are pulled into memory —
* whose `GROUP BY accountId, folder` is served by the existing `(accountId, folder, uid)` index.
*/
@Query(
"SELECT accountId, folder, COUNT(*) AS unreadCount FROM messages " +
"WHERE inInbox = 1 AND isRead = 0 GROUP BY accountId, folder",
)
fun observeUnreadCounts(): Flow<List<FolderUnreadCount>>
@Query("SELECT * FROM messages WHERE id = :id LIMIT 1")
suspend fun getById(id: String): MessageEntity?
/** Ids of an account's inbox rows (excludes transient server-search hits). */
@Query("SELECT id FROM messages WHERE accountId = :accountId AND inInbox = 1")
suspend fun getInboxIdsForAccount(accountId: String): List<String>
/**
* Body-less routing/flags projection for a single message (issue #186). The open path and the
* flag/move callers only need routing and flag columns, so pulling the whole `body` through
* SQLite's shared CursorWindow on every such read is pure over-fetch — [getById] (`SELECT *`) is
* reserved for the one read that actually returns the body to the reader. Served by the primary-key
* lookup, so no new index / migration (mirrors the [MessageSummary] projection).
*/
@Query(
"SELECT id, accountId, folder, uid, isRead, isStarred, bodyFetched, isHtml FROM messages " +
"WHERE id = :id LIMIT 1",
)
suspend fun getRouting(id: String): MessageRouting?
/** Body-less routing/flags projection for a set of messages — batch move/delete/expunge callers. */
@Query(
"SELECT id, accountId, folder, uid, isRead, isStarred, bodyFetched, isHtml FROM messages " +
"WHERE id IN (:ids)",
)
suspend fun getRoutingByIds(ids: List<String>): List<MessageRouting>
/** Ids of an account's synced rows in [folder] (excludes transient server-search hits). */
@Query("SELECT id FROM messages WHERE accountId = :accountId AND folder = :folder AND inInbox = 1")
suspend fun getSyncedIds(accountId: String, folder: String): List<String>
/** Ids of synced rows in [folder] whose body hasn't been cached yet (for aggressive prefetch). */
@Query(
"SELECT id FROM messages WHERE accountId = :accountId AND folder = :folder " +
"AND inInbox = 1 AND bodyFetched = 0",
)
suspend fun getUnfetchedIds(accountId: String, folder: String): List<String>
/** Inserts only new messages, leaving existing rows (and their cached bodies/flags) intact. */
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertNew(messages: List<MessageEntity>)
/** Of the given [ids], those that already have a row — lets a caller refresh only pre-existing rows. */
@Query("SELECT id FROM messages WHERE id IN (:ids)")
suspend fun existingIds(ids: List<String>): List<String>
/**
* Refreshes the display fields from the server without touching the cached body, the local
* read/star flags (which may hold an optimistic change the server hasn't reflected yet), or the
* inbox membership.
* Refreshes the display fields (and the materialized [MessageEntity.uid], keeping it fresh for
* rows migrated before the column existed) from the server without touching the cached body, the
* local read/star flags (which may hold an optimistic change the server hasn't reflected yet), or
* the inbox membership.
*/
@Query(
"UPDATE messages SET sender = :sender, senderEmail = :senderEmail, subject = :subject, " +
"timestampMillis = :timestampMillis WHERE id = :id",
"timestampMillis = :timestampMillis, uid = :uid WHERE id = :id",
)
suspend fun updateHeaderContent(
id: String,
@@ -39,11 +145,12 @@ interface MessageDao {
senderEmail: String,
subject: String,
timestampMillis: Long,
uid: Long,
)
/** Marks rows as belonging to the inbox (e.g. a former search-only row that the sync now returns). */
/** Marks rows as folder-synced (e.g. a former search-only row that the sync now returns). */
@Query("UPDATE messages SET inInbox = 1 WHERE id IN (:ids)")
suspend fun markInInbox(ids: List<String>)
suspend fun markSynced(ids: List<String>)
@Query("UPDATE messages SET body = :body, isHtml = :isHtml, snippet = :snippet, bodyFetched = 1 WHERE id = :id")
suspend fun updateBody(id: String, body: String, isHtml: Boolean, snippet: String)
@@ -57,16 +164,70 @@ interface MessageDao {
@Query("DELETE FROM messages WHERE id = :id")
suspend fun deleteById(id: String)
/** Optimistically removes moved/deleted rows; the next sync reconciles if a server op failed. */
@Query("DELETE FROM messages WHERE id IN (:ids)")
suspend fun deleteByIds(ids: List<String>)
@Query("DELETE FROM messages WHERE accountId = :accountId")
suspend fun deleteByAccount(accountId: String)
/** Clears only an account's inbox rows (leaves any in-flight search-only rows). */
@Query("DELETE FROM messages WHERE accountId = :accountId AND inInbox = 1")
suspend fun deleteInboxByAccount(accountId: String)
/** Clears an account's synced rows in [folder] (leaves any in-flight search-only rows). */
@Query("DELETE FROM messages WHERE accountId = :accountId AND folder = :folder AND inInbox = 1")
suspend fun deleteSyncedByAccountFolder(accountId: String, folder: String)
/** Drops inbox rows for an account that are no longer present on the server. */
@Query("DELETE FROM messages WHERE accountId = :accountId AND inInbox = 1 AND id NOT IN (:keepIds)")
suspend fun deleteInboxNotIn(accountId: String, keepIds: List<String>)
/**
* Windowed deletion reconcile for full-history sync (issue #12): within [folder], delete synced
* rows whose UID falls inside the freshly-fetched recent window (`uid >= minWindowUid`) but which
* the server no longer returns ([keepIds]). Rows below the window — older history fetched by the
* background backfill — are deliberately left intact, unlike a whole-folder "not in the recent
* set" reconcile, which would wipe that backfilled history.
*/
@Query(
"DELETE FROM messages WHERE accountId = :accountId AND folder = :folder AND inInbox = 1 " +
"AND uid >= :minWindowUid AND id NOT IN (:keepIds)",
)
suspend fun deleteSyncedInWindowNotIn(accountId: String, folder: String, minWindowUid: Long, keepIds: List<String>)
/**
* Lowest cached *resolved* UID among an account's synced rows in [folder] — the backfill
* boundary. Placeholder rows with `uid <= 0` (a row migrated before the `uid` column existed, or
* a fetch where the server failed to resolve the UID) are excluded: letting one collapse
* MIN(uid) to `<= 0` would make the backfiller page below a bound `fetchOlderThan` treats as
* "nothing older", falsely marking the folder fully backfilled (#95, matching the
* `minWindowUid` guard in MailSyncer). Null when no resolved-UID row exists, in which case
* backfill starts over from the newest message.
*/
@Query(
"SELECT MIN(uid) FROM messages WHERE accountId = :accountId AND folder = :folder " +
"AND inInbox = 1 AND uid > 0",
)
suspend fun lowestSyncedUid(accountId: String, folder: String): Long?
/** Number of an account's synced rows in [folder] (count-based retention floor / prune sizing). */
@Query("SELECT COUNT(*) FROM messages WHERE accountId = :accountId AND folder = :folder AND inInbox = 1")
suspend fun countSynced(accountId: String, folder: String): Int
/** Distinct folders that have at least one synced row for [accountId] (backfill/prune targets). */
@Query("SELECT DISTINCT folder FROM messages WHERE accountId = :accountId AND inInbox = 1")
suspend fun syncedFolders(accountId: String): List<String>
/** Ids of an account's synced rows older than [cutoffMillis] (age-based prune candidates, all folders). */
@Query("SELECT id FROM messages WHERE accountId = :accountId AND inInbox = 1 AND timestampMillis < :cutoffMillis")
suspend fun syncedIdsOlderThan(accountId: String, cutoffMillis: Long): List<String>
/**
* Ids of an account's synced rows in [folder] beyond the newest [keep] by ARRIVAL (server UID —
* count-based prune candidates). Ordering by UID (not by the Date header) matches the newest-by-UID
* recent window [org.libremail.mail.ImapClient.fetchRecent] keeps fresh, so a message with a high
* UID but an old Date isn't re-fetched by every sync and re-pruned by every cycle.
*/
@Query(
"SELECT id FROM messages WHERE accountId = :accountId AND folder = :folder AND inInbox = 1 " +
"AND id NOT IN (" +
"SELECT id FROM messages WHERE accountId = :accountId AND folder = :folder AND inInbox = 1 " +
"ORDER BY uid DESC LIMIT :keep)",
)
suspend fun syncedIdsBeyondCountInFolder(accountId: String, folder: String, keep: Int): List<String>
/** Removes transient server-search hits (called when search closes). */
@Query("DELETE FROM messages WHERE inInbox = 0")
@@ -0,0 +1,47 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.data.local.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Transaction
import kotlinx.coroutines.flow.Flow
import org.libremail.data.local.entity.SignatureEntity
@Dao
interface SignatureDao {
@Query("SELECT * FROM signatures WHERE accountId = :accountId ORDER BY isDefault DESC, name COLLATE NOCASE")
fun observeForAccount(accountId: String): Flow<List<SignatureEntity>>
@Query("SELECT * FROM signatures WHERE id = :id LIMIT 1")
suspend fun getById(id: String): SignatureEntity?
@Query("SELECT * FROM signatures WHERE accountId = :accountId AND isDefault = 1 LIMIT 1")
suspend fun getDefault(accountId: String): SignatureEntity?
@Query("SELECT * FROM signatures WHERE accountId = :accountId ORDER BY name COLLATE NOCASE LIMIT 1")
suspend fun firstForAccount(accountId: String): SignatureEntity?
@Query("SELECT COUNT(*) FROM signatures WHERE accountId = :accountId")
suspend fun countForAccount(accountId: String): Int
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(signature: SignatureEntity)
@Query("DELETE FROM signatures WHERE id = :id")
suspend fun delete(id: String)
@Query("UPDATE signatures SET isDefault = 0 WHERE accountId = :accountId")
suspend fun clearDefault(accountId: String)
@Query("UPDATE signatures SET isDefault = 1 WHERE id = :id")
suspend fun markDefault(id: String)
/** Makes [id] the account's sole default in one transaction (clears the others first). */
@Transaction
suspend fun setDefault(accountId: String, id: String) {
clearDefault(accountId)
markDefault(id)
}
}
@@ -0,0 +1,37 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.data.local.entity
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.PrimaryKey
/**
* Per-account preferences, one row per account. The [accountId] foreign key cascades on delete, so
* removing an account also removes its settings. It is the primary key, so it is already indexed
* (no extra `@Index` needed for the foreign key).
*/
@Entity(
tableName = "account_settings",
foreignKeys = [
ForeignKey(
entity = AccountEntity::class,
parentColumns = ["id"],
childColumns = ["accountId"],
onDelete = ForeignKey.CASCADE,
),
],
)
data class AccountSettingsEntity(
@PrimaryKey val accountId: String,
val signature: String = "",
val signatureEnabled: Boolean = true,
val notificationsEnabled: Boolean = true,
/**
* Per-account device-only retention overrides (issue #13). `null` means "use the global default";
* `0` means an explicit "keep everything" (no limit). A positive value caps how many messages
* ([retentionCount], newest per folder) or how many months of history ([retentionMonths]) are kept
* on this device — the server copy is never touched.
*/
val retentionCount: Int? = null,
val retentionMonths: Int? = null,
)
@@ -25,4 +25,10 @@ data class AttachmentEntity(
val filename: String,
val mimeType: String,
val sizeBytes: Long,
/**
* The normalized `Content-ID` when this part is an inline image (`<img src="cid:...">`) — null for
* an ordinary attachment. Inline rows are cached so the reader's WebView can resolve `cid:`
* requests offline, but are filtered out of the displayed attachment list (issue #133).
*/
val contentId: String? = null,
)
@@ -0,0 +1,25 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.data.local.entity
import androidx.room.Entity
/**
* Per-(account, folder) progress of the full-history backfill (issue #12). Lets the background
* backfill worker page backwards through a folder resumably: it survives process death and network
* loss because the boundary is persisted after every batch.
*
* Not foreign-keyed to `accounts` (matching `messages`/`folders`); it is cleared explicitly when an
* account is removed.
*/
@Entity(tableName = "backfill_progress", primaryKeys = ["accountId", "folder"])
data class BackfillProgressEntity(
val accountId: String,
val folder: String,
/**
* Exclusive upper UID bound for the next page: the next batch fetches server messages with
* UID < this value. Lowered to the batch's lowest UID after each successful page.
*/
val nextBeforeUid: Long,
/** True once the whole folder (down to the retention floor, if any) has been cached. */
val complete: Boolean = false,
)
@@ -10,7 +10,4 @@ import androidx.room.PrimaryKey
* plaintext.
*/
@Entity(tableName = "credentials")
data class CredentialEntity(
@PrimaryKey val accountId: String,
val encryptedSecret: String,
)
data class CredentialEntity(@PrimaryKey val accountId: String, val encryptedSecret: String)
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.data.local.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
@@ -11,9 +12,12 @@ data class DraftEntity(
val accountId: String?,
val toAddresses: String,
val ccAddresses: String,
@ColumnInfo(defaultValue = "") val bccAddresses: String = "",
val subject: String,
val body: String,
val updatedAt: Long,
/** JSON array of the draft's attachments ([uri, name] pairs); empty string when there are none. */
val attachments: String = "",
/** HTML form of [body] when the draft carries formatting; null for plaintext drafts. */
val bodyHtml: String? = null,
)
@@ -0,0 +1,31 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.data.local.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
/** A cached IMAP folder for an account. [sortOrder] preserves the server's listing order. */
@Entity(tableName = "folders", primaryKeys = ["accountId", "fullName"])
data class FolderEntity(
val accountId: String,
val fullName: String,
val displayName: String,
/** The [org.libremail.domain.model.FolderRole] name. */
val role: String,
val selectable: Boolean,
val sortOrder: Int,
/**
* True when the server advertises this as a special-use folder (RFC 6154, e.g. `\Drafts`,
* `\Junk`, `\All`). Distinguishes a provider's built-in folder from a same-named user folder
* when the drawer de-duplicates display labels.
*/
@ColumnInfo(defaultValue = "0") val specialUse: Boolean = false,
/**
* The hierarchy separator the server reported for this folder in its LIST response (e.g. "/" for
* Gmail, "." for some servers), stored as a one-character string. Null for folders cached before
* this column existed (legacy rows) or on a flat namespace; the drawer's parent-label logic then
* infers the separator from the folder name (issue #66). Nullable with no SQL default (the same
* pattern as the drafts `bodyHtml` column), so the next folder refresh backfills the real value.
*/
val hierarchyDelimiter: String? = null,
)
@@ -0,0 +1,9 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.data.local.entity
/**
* Aggregate projection of [MessageEntity] counting unread, folder-synced rows per (account, folder).
* Produced by `MessageDao.observeUnreadCounts`' `GROUP BY` query — never a stored table — so the
* counts come straight from SQLite without pulling any message rows into memory.
*/
data class FolderUnreadCount(val accountId: String, val folder: String, val unreadCount: Int)
@@ -1,13 +1,17 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.data.local.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
@Entity(
tableName = "messages",
indices = [Index("accountId"), Index("timestampMillis")],
// The (accountId, folder, uid) index serves the folder-scoped UID probes the backfill/reconcile
// hot paths run on every page/sync: MIN(uid) (lowestSyncedUid) and the uid >= window bound
// (deleteSyncedInWindowNotIn / syncedIdsBeyondCountInFolder).
indices = [Index("accountId"), Index("timestampMillis"), Index("accountId", "folder", "uid")],
)
data class MessageEntity(
@PrimaryKey val id: String,
@@ -21,8 +25,17 @@ data class MessageEntity(
val timestampMillis: Long,
val isRead: Boolean,
val isStarred: Boolean,
/** True for inbox-synced rows; false for transient server-search hits (purged on search close). */
/** The folder this message belongs to, e.g. "INBOX" or "[Gmail]/Sent Mail". */
@ColumnInfo(defaultValue = "INBOX") val folder: String = "INBOX",
/** True for folder-synced rows; false for transient server-search hits (purged on search close). */
val inInbox: Boolean = true,
/** True once the body has been fetched from the server (distinguishes "not fetched" from "empty body"). */
val bodyFetched: Boolean = false,
/**
* The server IMAP UID as a number (also embedded in [id]). Materialized as a column so full-history
* backfill can page by "lowest cached UID" and foreground sync can reconcile only the recent UID
* window without deleting older, backfilled history. 0 for rows migrated before this column existed
* (refreshed to the real UID on the next sync).
*/
@ColumnInfo(defaultValue = "0") val uid: Long = 0L,
)
@@ -0,0 +1,25 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.data.local.entity
/**
* Body-less projection of [MessageEntity] for the repository's routing and flag decisions: every
* field needed to decide whether to fetch a body, push a SEEN/FLAGGED change, or resolve the
* message's folder/account — but *not* the potentially large `body` blob.
*
* The open path and the flag/move callers (`openMessage`, `downloadAttachment`, `setStarred`,
* `deleteMessage`, `expunge`, `moveByRole`, `moveToFolder`, `buildReplyDraft`, `prefetchMessage`)
* never render the body, so pulling it through SQLite's shared CursorWindow on every such read is
* pure over-fetch (issue #186). `MessageDao.getById` (`SELECT *`) is reserved for the one read that
* actually returns the body to the reader. Mirrors the existing [MessageSummary] projection — served
* by the primary-key lookup, so no new index and no schema migration.
*/
data class MessageRouting(
val id: String,
val accountId: String,
val folder: String,
val uid: Long,
val isRead: Boolean,
val isStarred: Boolean,
val bodyFetched: Boolean,
val isHtml: Boolean,
)
@@ -0,0 +1,26 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.data.local.entity
/**
* Lightweight projection of [MessageEntity] for the mailbox list: every column the list renders
* or searches, but *not* the potentially large `body`/`isHtml`.
*
* The list observes every cached message at once, so selecting full HTML bodies would drag them
* all through SQLite's shared (~2 MB) CursorWindow and overflow it once enough large bodies are
* cached — crashing with "Couldn't read row N from CursorWindow" (issue #51). Bodies are read
* lazily, one message at a time, via `MessageDao.getById` when a message is opened.
*/
data class MessageSummary(
val id: String,
val accountId: String,
val sender: String,
val senderEmail: String,
val subject: String,
val snippet: String,
val timestampMillis: Long,
val isRead: Boolean,
val isStarred: Boolean,
val folder: String,
val inInbox: Boolean,
val bodyFetched: Boolean,
)
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.data.local.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
@@ -11,8 +12,18 @@ data class OutboxEntity(
val accountId: String,
val toAddresses: String,
val ccAddresses: String,
@ColumnInfo(defaultValue = "") val bccAddresses: String = "",
val subject: String,
val body: String,
val createdAt: Long,
val lastError: String? = null,
/** HTML form of [body] when composed with formatting; null sends `text/plain` only. */
val bodyHtml: String? = null,
/**
* JSON metadata for the staged attachments, in staging-index order (the same
* `{uri, name, contentId?, isInline?}` shape drafts use), or "" when there are none. The bytes
* live on disk under `cacheDir/outbox/<id>/<index>/`; this column carries each part's `Content-ID`
* and inline flag so the send worker can pair an inline image's cid with its file.
*/
@ColumnInfo(defaultValue = "") val attachments: String = "",
)
@@ -0,0 +1,32 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.data.local.entity
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
/**
* One saved signature. The [accountId] foreign key cascades on delete, so removing an account drops
* its signatures. [isDefault] marks the one auto-inserted when composing from the account; the
* repository keeps at most one default per account.
*/
@Entity(
tableName = "signatures",
foreignKeys = [
ForeignKey(
entity = AccountEntity::class,
parentColumns = ["id"],
childColumns = ["accountId"],
onDelete = ForeignKey.CASCADE,
),
],
indices = [Index("accountId")],
)
data class SignatureEntity(
@PrimaryKey val id: String,
val accountId: String,
val name: String,
val contentHtml: String,
val isDefault: Boolean = false,
)
@@ -1,43 +1,56 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.data.repository
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import org.libremail.data.local.dao.AccountDao
import org.libremail.data.local.dao.BackfillProgressDao
import org.libremail.data.local.dao.FolderDao
import org.libremail.data.local.dao.MessageDao
import org.libremail.data.local.toDomain
import org.libremail.data.local.toEntity
import org.libremail.data.local.toImapParams
import org.libremail.data.security.CredentialStore
import org.libremail.data.settings.AccountSettingsRepository
import org.libremail.data.sync.SyncScheduler
import org.libremail.domain.model.Account
import org.libremail.domain.model.ImapConnectionParams
import org.libremail.domain.repository.AccountRepository
import org.libremail.mail.ImapClient
import org.libremail.notifications.MailNotifier
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class AccountRepositoryImpl @Inject constructor(
private val accountDao: AccountDao,
private val messageDao: MessageDao,
private val folderDao: FolderDao,
private val backfillProgressDao: BackfillProgressDao,
private val credentialStore: CredentialStore,
private val imapClient: ImapClient,
private val syncScheduler: SyncScheduler,
private val accountSettingsRepository: AccountSettingsRepository,
private val mailNotifier: MailNotifier,
) : AccountRepository {
override fun observeAccounts(): Flow<List<Account>> =
accountDao.observeAll().map { rows -> rows.map { it.toDomain() } }
override fun observeAccounts(): Flow<List<Account>> = accountDao.observeAll().map { rows ->
rows.map { it.toDomain() }
}
override suspend fun testConnection(params: ImapConnectionParams): Result<List<String>> =
runCatching { imapClient.listFolders(params) }
override suspend fun testConnection(params: ImapConnectionParams): Result<List<String>> = runCatching {
imapClient.listFolders(params).map { it.fullName }
}
override suspend fun addImapAccount(account: Account, password: String): Result<List<String>> = runCatching {
val folders = imapClient.listFolders(account.toImapParams(secret = password, useXoauth2 = false))
accountDao.upsert(account.toEntity())
accountSettingsRepository.ensureDefaults(account.id)
credentialStore.saveSecret(account.id, password)
mailNotifier.ensureAccountChannel(account)
syncScheduler.syncNow()
folders
syncScheduler.backfillNow() // start caching this account's full history in the background (#12)
folders.map { it.fullName }
}
override suspend fun addOutlookAccount(
@@ -48,15 +61,27 @@ class AccountRepositoryImpl @Inject constructor(
val account = Account.outlook(email)
val folders = imapClient.listFolders(account.toImapParams(secret = accessToken, useXoauth2 = true))
accountDao.upsert(account.toEntity())
accountSettingsRepository.ensureDefaults(account.id)
credentialStore.saveSecret(account.id, authStateJson)
mailNotifier.ensureAccountChannel(account)
syncScheduler.syncNow()
folders
syncScheduler.backfillNow() // start caching this account's full history in the background (#12)
folders.map { it.fullName }
}
override suspend fun deleteAccount(id: String) {
accountDao.deleteById(id)
credentialStore.delete(id)
// Remove the account's cached mail (attachment rows cascade via the foreign key).
mailNotifier.deleteAccountChannel(id)
// Remove the account's cached mail (attachment rows cascade via the foreign key), folders, and
// backfill progress. The account_settings row is removed automatically by its cascading FK.
messageDao.deleteByAccount(id)
folderDao.deleteForAccount(id)
backfillProgressDao.deleteForAccount(id)
}
override suspend fun resetBackfillProgress(accountId: String?) {
if (accountId != null) backfillProgressDao.deleteForAccount(accountId) else backfillProgressDao.deleteAll()
syncScheduler.backfillNow()
}
}
@@ -3,34 +3,58 @@ package org.libremail.data.repository
import android.content.Context
import android.net.Uri
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.PagingData
import androidx.paging.map
import dagger.hilt.android.qualifiers.ApplicationContext
import jakarta.mail.Flags
import java.io.File
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.libremail.data.ReplyBuilder
import org.libremail.data.SignatureBlock
import org.libremail.data.Snippet
import org.libremail.data.attachmentCacheDir
import org.libremail.data.local.dao.AccountDao
import org.libremail.data.local.dao.AttachmentDao
import org.libremail.data.local.dao.DraftDao
import org.libremail.data.local.dao.FolderDao
import org.libremail.data.local.dao.MessageDao
import org.libremail.data.local.dao.OutboxDao
import org.libremail.data.local.entity.FolderEntity
import org.libremail.data.local.entity.MessageRouting
import org.libremail.data.local.entity.OutboxEntity
import org.libremail.data.local.toDomain
import org.libremail.data.local.toEntity
import org.libremail.data.local.toOutgoingAttachmentsJson
import org.libremail.data.settings.AccountSettingsRepository
import org.libremail.data.settings.SignatureRepository
import org.libremail.data.sync.MailConnectionFactory
import org.libremail.data.sync.SendScheduler
import org.libremail.domain.model.Account
import org.libremail.domain.model.Attachment
import org.libremail.domain.model.Draft
import org.libremail.domain.model.Folder
import org.libremail.domain.model.FolderRole
import org.libremail.domain.model.ImapConnectionParams
import org.libremail.domain.model.InlineImage
import org.libremail.domain.model.Message
import org.libremail.domain.model.OutboxMessage
import org.libremail.domain.model.OutgoingAttachment
import org.libremail.domain.model.OutgoingMessage
import org.libremail.domain.model.ReplyMode
import org.libremail.domain.model.UnreadCount
import org.libremail.domain.repository.MailRepository
import org.libremail.mail.DownloadedAttachment
import org.libremail.mail.ImapClient
import java.io.File
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class MailRepositoryImpl @Inject constructor(
@@ -40,57 +64,329 @@ class MailRepositoryImpl @Inject constructor(
private val attachmentDao: AttachmentDao,
private val outboxDao: OutboxDao,
private val draftDao: DraftDao,
private val folderDao: FolderDao,
private val imapClient: ImapClient,
private val connectionFactory: MailConnectionFactory,
private val sendScheduler: SendScheduler,
private val accountSettingsRepository: AccountSettingsRepository,
private val signatureRepository: SignatureRepository,
) : MailRepository {
override fun observeMessages(): Flow<List<Message>> =
messageDao.observeAll().map { rows -> rows.map { it.toDomain() } }
// Application-lifetime scope for fire-and-forget server pushes that must outlive the caller — e.g.
// openMessage() returning to the reader screen before the SEEN flag reaches the server (#148). Same
// pattern as LibreMailApplication.appScope / IdleService.scope: this class is @Singleton (bound to
// Hilt's SingletonComponent), so the scope's lifetime is the process's, not any one caller's coroutine.
private val backgroundScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
override fun observeFolderMessages(accountId: String, folder: String): Flow<List<Message>> =
messageDao.observeFolderSummaries(accountId, folder).map { rows -> rows.map { it.toDomain() } }
override fun observeUnifiedFolderMessages(folder: String): Flow<List<Message>> =
messageDao.observeUnifiedFolderSummaries(folder).map { rows -> rows.map { it.toDomain() } }
override fun pagedUnifiedFolderMessages(folder: String): Flow<PagingData<Message>> = Pager(
config = PagingConfig(
// A page comfortably exceeds a screenful so scrolling rarely waits on a load; loading
// three pages up front fills the first viewport without a visible gap. Placeholders
// are off: the row height varies (snippet/account label), so a fixed-height placeholder
// would jump, and the list never needs a scrollbar sized to the full (uncounted) inbox.
pageSize = MAILBOX_PAGE_SIZE,
initialLoadSize = MAILBOX_PAGE_SIZE * 3,
enablePlaceholders = false,
),
pagingSourceFactory = { messageDao.pagingUnifiedFolderSummaries(folder) },
).flow.map { page -> page.map { it.toDomain() } }
override fun observeFolders(accountId: String): Flow<List<Folder>> =
folderDao.observeForAccount(accountId).map { rows ->
rows.map { it.toDomain() }
}
override fun observeUnreadCounts(): Flow<List<UnreadCount>> = messageDao.observeUnreadCounts().map { rows ->
rows.map { it.toDomain() }
}
override suspend fun refreshFolders(accountId: String): Result<Unit> = runCatching {
val account = accountDao.getById(accountId)?.toDomain() ?: error("Account not found")
val params = connectionFactory.imapParamsFor(account)
val entities = imapClient.listFolders(params)
.mapIndexed { index, folder -> folder.toEntity(accountId, index) }
folderDao.replaceForAccount(accountId, entities)
}
override suspend fun getMessage(id: String): Message? = messageDao.getById(id)?.toDomain()
override suspend fun openMessage(id: String): Result<Message> = runCatching {
val entity = messageDao.getById(id) ?: error("Message not found")
val account = accountDao.getById(entity.accountId)?.toDomain()
if (account != null) {
val params = connectionFactory.imapParamsFor(account)
if (!entity.bodyFetched) {
val content = imapClient.fetchBodyMarkingSeen(params, uidOf(id))
messageDao.updateBody(id, content.body, content.isHtml, snippetOf(content.body))
attachmentDao.replaceForMessage(id, content.attachments.map { it.toEntity(id) })
messageDao.setRead(id, true)
} else if (!entity.isRead) {
runCatching { imapClient.setFlag(params, uidOf(id), Flags.Flag.SEEN, true) }
messageDao.setRead(id, true)
override suspend fun openMessage(id: String): Result<Message> = withContext(Dispatchers.IO) {
runCatching {
// Route on the body-less projection: a cached, already-read message needs no account, no
// credentials, and no network, so it skips the Keystore decrypt + DataStore read that
// resolving connection params costs (issue #186). Only the fetch / SEEN-push branches below
// pull the account and resolve params, and each does so lazily right where it is needed.
val routing = messageDao.getRouting(id) ?: error("Message not found")
if (!routing.bodyFetched || !routing.isRead) {
val account = accountDao.getById(routing.accountId)?.toDomain()
if (account != null && !routing.bodyFetched) {
val params = connectionFactory.imapParamsFor(account)
val content = imapClient.fetchBodyMarkingSeen(params, routing.folder, uidOf(id))
messageDao.updateBody(id, content.body, content.isHtml, Snippet.of(content.body, content.isHtml))
attachmentDao.replaceForMessage(id, content.attachments.map { it.toEntity(id) })
messageDao.setRead(id, true)
} else if (account != null) {
// Optimistic, local-only: the reader can render as soon as this returns. The SEEN flag
// still needs to reach the server, but that IMAP round trip (connection + STORE) must not
// sit on this path (#148/#186) — the body/attachments are already fully local. Pushed on
// backgroundScope, which outlives this call.
val params = connectionFactory.imapParamsFor(account)
messageDao.setRead(id, true)
pushSeenFlagInBackground(params, routing.folder, id)
}
}
// The single full-body read, reserved for the value the reader actually renders (issue #186).
messageDao.getById(id)?.toDomain() ?: error("Message not found")
}
}
/**
* Best-effort, fire-and-forget propagation of the SEEN flag to the server, off the message-open
* critical path (#148). Retries a few times with a short backoff, then gives up silently: local state
* is already correct (the caller set it before launching this), so a permanent failure here just means
* the server's copy stays "unread" until something else touches the flag — e.g. the message is opened
* from another client, or a future sync gains upward read-state reconciliation. Today's folder sync
* does NOT do that: it deliberately leaves cached read/star flags alone when refreshing headers from
* the server (see `MessageDao.updateHeaderContent`), so it protects an optimistic local flag from
* being clobbered by stale server state, but it does not re-drive a push that never reached the server
* either. This retry is in-memory only and does not survive process death mid-backoff.
*/
private fun pushSeenFlagInBackground(params: ImapConnectionParams, folder: String, id: String) {
backgroundScope.launch {
var attempt = 0
while (true) {
attempt++
val result = runCatching { imapClient.setFlag(params, folder, uidOf(id), Flags.Flag.SEEN, true) }
if (result.isSuccess || attempt >= SEEN_FLAG_PUSH_MAX_ATTEMPTS) return@launch
delay(SEEN_FLAG_RETRY_BACKOFF_MS * attempt)
}
}
messageDao.getById(id)?.toDomain() ?: error("Message not found")
}
override fun observeAttachments(messageId: String): Flow<List<Attachment>> =
attachmentDao.observeForMessage(messageId).map { rows -> rows.map { it.toDomain() } }
attachmentDao.observeForMessage(messageId).map { rows ->
rows.map { it.toDomain() }
}
override suspend fun downloadAttachment(messageId: String, partIndex: Int): Result<File> = runCatching {
val entity = messageDao.getById(messageId) ?: error("Message not found")
val account = accountDao.getById(entity.accountId)?.toDomain() ?: error("Account not found")
override suspend fun inlineImages(messageId: String): List<InlineImage> = withContext(Dispatchers.IO) {
// Resolve the message's account/folder once (body-less), then reuse the on-disk cache per cid:
// image — no per-image message re-read or attachment re-query (the old downloadAttachment N+1, #186).
val routing = messageDao.getRouting(messageId) ?: return@withContext emptyList()
val parts = attachmentDao.getForMessage(messageId)
parts.filter { it.contentId != null }.mapNotNull { row ->
// Reuse the on-disk attachment cache (download once, then instant + offline). A failed
// fetch just omits that image, leaving a broken <img> rather than failing the open.
val file = runCatching {
ensureAttachmentFile(messageId, routing.accountId, routing.folder, row.partIndex, row.filename)
}.getOrNull() ?: return@mapNotNull null
InlineImage(contentId = row.contentId!!, mimeType = row.mimeType, bytes = file.readBytes())
}
}
override suspend fun downloadAttachment(messageId: String, partIndex: Int): Result<File> =
withContext(Dispatchers.IO) {
runCatching {
val routing = messageDao.getRouting(messageId) ?: error("Message not found")
val meta = attachmentDao.getForMessage(messageId).firstOrNull { it.partIndex == partIndex }
ensureAttachmentFile(
messageId,
routing.accountId,
routing.folder,
partIndex,
meta?.filename ?: "attachment",
)
}
}
/**
* Returns the on-disk file for one attachment part, downloading and caching it on first use so it
* then opens instantly and offline. Takes the message's already-resolved account/folder so a batch
* loop (e.g. [inlineImages]) resolves them once instead of re-reading the message row per part (#186).
* Connection params are resolved lazily — only when the file is missing and must be fetched.
*/
private suspend fun ensureAttachmentFile(
messageId: String,
accountId: String,
folder: String,
partIndex: Int,
filename: String,
): File {
val target = attachmentFile(messageId, partIndex, filename)
// Reuse a previously downloaded (or pre-fetched) file so it opens instantly and offline.
if (target.exists() && target.length() > 0L) return target
val account = accountDao.getById(accountId)?.toDomain() ?: error("Account not found")
val params = connectionFactory.imapParamsFor(account)
saveToCache(imapClient.fetchAttachment(params, uidOf(messageId), partIndex))
val downloaded = imapClient.fetchAttachment(params, folder, uidOf(messageId), partIndex)
target.parentFile?.mkdirs()
target.outputStream().use { it.write(downloaded.bytes) }
return target
}
override suspend fun downloadedAttachmentParts(messageId: String): Set<Int> = withContext(Dispatchers.IO) {
attachmentDao.getForMessage(messageId)
.filter {
val file = attachmentFile(messageId, it.partIndex, it.filename)
file.exists() && file.length() > 0L
}
.map { it.partIndex }
.toSet()
}
override suspend fun prefetchMessage(messageId: String): Result<Unit> = runCatching {
val routing = messageDao.getRouting(messageId) ?: return@runCatching
val account = accountDao.getById(routing.accountId)?.toDomain() ?: return@runCatching
// Cache the body (peek, so prefetching never marks the message read) and its attachment metadata.
if (!routing.bodyFetched) {
val params = connectionFactory.imapParamsFor(account)
val content = imapClient.fetchBodyPeek(params, routing.folder, uidOf(messageId))
messageDao.updateBody(messageId, content.body, content.isHtml, Snippet.of(content.body, content.isHtml))
attachmentDao.replaceForMessage(messageId, content.attachments.map { it.toEntity(messageId) })
}
// Auto-download every attachment's bytes into the persistent per-part cache (skips ones present).
attachmentDao.getForMessage(messageId).forEach { attachment ->
downloadAttachment(messageId, attachment.partIndex)
}
}
override suspend fun setStarred(id: String, starred: Boolean): Result<Unit> = runCatching {
messageDao.setStarred(id, starred) // optimistic; next sync reconciles on failure
accountFor(id)?.let { account ->
imapClient.setFlag(connectionFactory.imapParamsFor(account), uidOf(id), Flags.Flag.FLAGGED, starred)
val routing = messageDao.getRouting(id)
val account = routing?.let { accountDao.getById(it.accountId)?.toDomain() }
if (routing != null && account != null) {
imapClient.setFlag(
connectionFactory.imapParamsFor(account),
routing.folder,
uidOf(id),
Flags.Flag.FLAGGED,
starred,
)
}
Unit
}
override suspend fun deleteMessage(id: String): Result<Unit> = runCatching {
val account = accountFor(id)
val routing = messageDao.getRouting(id)
val account = routing?.let { accountDao.getById(it.accountId)?.toDomain() }
messageDao.deleteById(id) // optimistic; reappears on next sync if the server delete failed
account?.let { imapClient.deleteMessage(connectionFactory.imapParamsFor(it), uidOf(id)) }
Unit
if (routing != null && account != null) {
imapClient.deleteMessage(connectionFactory.imapParamsFor(account), routing.folder, uidOf(id))
}
}
override suspend fun archive(ids: List<String>): Result<Unit> =
moveByRole(ids, FolderRole.ARCHIVE, fallbackExpunge = false)
override suspend fun reportSpam(ids: List<String>): Result<Unit> =
moveByRole(ids, FolderRole.SPAM, fallbackExpunge = false)
override suspend fun trash(ids: List<String>): Result<Unit> =
moveByRole(ids, FolderRole.TRASH, fallbackExpunge = true)
override suspend fun expunge(ids: List<String>): Result<Unit> = runCatching {
val routings = messageDao.getRoutingByIds(ids)
messageDao.deleteByIds(ids) // optimistic
forEachAccountFolder(routings) { params, folder, group ->
group.forEach { imapClient.deleteMessage(params, folder, uidOf(it.id)) }
}
}
override suspend fun moveToFolder(ids: List<String>, destFolderFullName: String): Result<Unit> = runCatching {
val routings = messageDao.getRoutingByIds(ids)
messageDao.deleteByIds(ids) // optimistic
forEachAccountFolder(routings) { params, folder, group ->
if (folder != destFolderFullName) {
imapClient.moveMessages(params, folder, group.map { uidOf(it.id) }, destFolderFullName)
}
}
}
override suspend fun buildReplyDraft(messageId: String, mode: ReplyMode): Result<String> = runCatching {
val routing = messageDao.getRouting(messageId) ?: error("Message not found")
val account = accountDao.getById(routing.accountId)?.toDomain() ?: error("Account not found")
val params = connectionFactory.imapParamsFor(account)
val context = imapClient.fetchForReply(params, routing.folder, uidOf(messageId))
val content = ReplyBuilder.build(context, mode, account.email)
// Bake the sending account's default signature into the reply/forward body — above the quoted
// original — so it round-trips as part of the draft (compose won't re-append for drafts). Both
// the plaintext and HTML forms are stored so the reply can go out as multipart/alternative.
val settings = accountSettingsRepository.get(routing.accountId)
val sig = if (settings.signatureEnabled) {
SignatureBlock.of(signatureRepository.getDefault(routing.accountId))
} else {
SignatureBlock.EMPTY
}
val draftId = UUID.randomUUID().toString()
saveDraft(
Draft(
id = draftId,
accountId = routing.accountId,
to = content.to,
cc = content.cc,
subject = content.subject,
body = sig.plain + content.body,
updatedAt = System.currentTimeMillis(),
bodyHtml = sig.html + content.bodyHtml,
attachments = emptyList(),
),
)
draftId
}
/**
* Moves messages to each account's folder for [role], resolving the destination per account so the
* unified inbox works. Removes local rows first (optimistic; reconciled on the next sync). When the
* role folder is missing: trash falls back to a permanent expunge ([fallbackExpunge]); others fail.
*/
private suspend fun moveByRole(ids: List<String>, role: FolderRole, fallbackExpunge: Boolean): Result<Unit> =
runCatching {
val routings = messageDao.getRoutingByIds(ids)
messageDao.deleteByIds(ids) // optimistic
val destByAccount = routings.map { it.accountId }.distinct()
.associateWith { resolveRoleFolder(it, role) }
forEachAccountFolder(routings) { params, folder, group ->
when (val dest = destByAccount[group.first().accountId]) {
null ->
if (fallbackExpunge) {
group.forEach { imapClient.deleteMessage(params, folder, uidOf(it.id)) }
} else {
error("No ${role.name.lowercase()} folder for this account")
}
else -> imapClient.moveMessages(params, folder, group.map { uidOf(it.id) }, dest)
}
}
}
/** Groups [routings] by account then source folder and runs [block] once per (account, folder) group. */
private suspend fun forEachAccountFolder(
routings: List<MessageRouting>,
block: suspend (params: ImapConnectionParams, folder: String, group: List<MessageRouting>) -> Unit,
) {
routings.groupBy { it.accountId }.forEach { (accountId, accountMessages) ->
val account = accountDao.getById(accountId)?.toDomain() ?: return@forEach
val params = connectionFactory.imapParamsFor(account)
accountMessages.groupBy { it.folder }.forEach { (folder, group) -> block(params, folder, group) }
}
}
/**
* Resolves the full name of an account's folder for [role], refreshing the cache once if needed.
* Among same-role selectable folders (e.g. `[Gmail]/Spam` via RFC 6154 `\Junk` plus a user label
* "Spam" matched by name), the server-advertised special-use folder wins regardless of LIST order,
* so mail reaches the provider's built-in mailbox; absent one, the earliest LISTed folder is kept
* (`maxByOrNull` returns the first max).
*/
private suspend fun resolveRoleFolder(accountId: String, role: FolderRole): String? {
fun pick(folders: List<FolderEntity>) =
folders.filter { it.role == role.name && it.selectable }.maxByOrNull { it.specialUse }?.fullName
pick(folderDao.getForAccountOnce(accountId))?.let { return it }
// The folder cache can be cold (the user may not have opened the drawer yet); refresh and retry.
runCatching { refreshFolders(accountId) }
return pick(folderDao.getForAccountOnce(accountId))
}
/** Queues the message in the outbox and triggers the send worker; delivery happens in the background. */
@@ -104,9 +400,14 @@ class MailRepositoryImpl @Inject constructor(
accountId = outgoing.accountId,
toAddresses = outgoing.to,
ccAddresses = outgoing.cc,
bccAddresses = outgoing.bcc,
subject = outgoing.subject,
body = outgoing.body,
createdAt = System.currentTimeMillis(),
bodyHtml = outgoing.bodyHtml,
// Persist the cid↔file pairing (in staging-index order) so the send worker can attach
// an inline image with its Content-ID even though the files are looked up by index.
attachments = outgoing.attachments.toOutgoingAttachmentsJson(),
),
)
sendScheduler.sendNow()
@@ -130,8 +431,7 @@ class MailRepositoryImpl @Inject constructor(
}
}
override fun observeDrafts(): Flow<List<Draft>> =
draftDao.observeAll().map { rows -> rows.map { it.toDomain() } }
override fun observeDrafts(): Flow<List<Draft>> = draftDao.observeAll().map { rows -> rows.map { it.toDomain() } }
override suspend fun getDraft(id: String): Draft? = draftDao.getById(id)?.toDomain()
@@ -139,8 +439,9 @@ class MailRepositoryImpl @Inject constructor(
override suspend fun deleteDraft(id: String) = draftDao.delete(id)
override fun observeOutbox(): Flow<List<OutboxMessage>> =
outboxDao.observeAll().map { rows -> rows.map { it.toDomain() } }
override fun observeOutbox(): Flow<List<OutboxMessage>> = outboxDao.observeAll().map { rows ->
rows.map { it.toDomain() }
}
override suspend fun cancelOutboxMessage(id: String) {
outboxDao.delete(id)
@@ -149,49 +450,55 @@ class MailRepositoryImpl @Inject constructor(
override suspend fun retryOutbox() = sendScheduler.sendNow()
override suspend fun searchServer(query: String) {
accountDao.getAll().forEach { entity ->
val account = entity.toDomain()
runCatching {
val results = imapClient.search(connectionFactory.imapParamsFor(account), query, SEARCH_LIMIT)
// Mark hits as non-inbox so they show only while searching (and never overwrite the
// inbox membership of a row that is genuinely in the inbox).
val entities = results.map { it.toEntity(account.id, inInbox = false) }
messageDao.insertNew(entities)
entities.forEach {
messageDao.updateHeaderContent(
id = it.id,
sender = it.sender,
senderEmail = it.senderEmail,
subject = it.subject,
timestampMillis = it.timestampMillis,
)
override suspend fun searchServer(query: String, accountId: String?, folder: String) {
accountDao.getAll()
.filter { accountId == null || it.id == accountId }
.forEach { entity ->
val account = entity.toDomain()
runCatching {
val params = connectionFactory.imapParamsFor(account)
val results = imapClient.search(params, folder, query, SEARCH_LIMIT)
// Mark hits as search-only (inInbox = false) so they show only while searching, and
// never overwrite the synced membership of a row that is genuinely in the folder.
val entities = results.map { it.toEntity(account.id, folder, inInbox = false) }
messageDao.insertNew(entities)
entities.forEach {
messageDao.updateHeaderContent(
id = it.id,
sender = it.sender,
senderEmail = it.senderEmail,
subject = it.subject,
timestampMillis = it.timestampMillis,
uid = it.uid,
)
}
}
}
}
}
override suspend fun clearSearchResults() = messageDao.deleteSearchRows()
/** Writes downloaded bytes to a private cache file that the FileProvider can share. */
private fun saveToCache(attachment: DownloadedAttachment): File {
val dir = File(context.cacheDir, "attachments").apply { mkdirs() }
val safeName = attachment.filename.substringAfterLast('/').substringAfterLast('\\').ifBlank { "attachment" }
return File(dir, safeName).also { file ->
file.outputStream().use { it.write(attachment.bytes) }
}
}
private suspend fun accountFor(id: String): Account? {
val entity = messageDao.getById(id) ?: return null
return accountDao.getById(entity.accountId)?.toDomain()
/**
* Deterministic cache path for one attachment part, under the FileProvider-shared `attachments/`
* dir. Keying by message id + part index lets prefetch and on-demand download share the same file
* and avoids filename collisions between messages.
*/
private fun attachmentFile(messageId: String, partIndex: Int, filename: String): File {
val safeName = filename.substringAfterLast('/').substringAfterLast('\\').ifBlank { "attachment" }
return File(attachmentCacheDir(context.cacheDir, messageId), "$partIndex/$safeName")
}
}
private const val SEARCH_LIMIT = 50
/** Rows per page for the unified inbox (issue #124) — a page is a few screenfuls of message rows. */
private const val MAILBOX_PAGE_SIZE = 40
/** Attempts for the background best-effort SEEN-flag push before giving up silently (issue #148). */
private const val SEEN_FLAG_PUSH_MAX_ATTEMPTS = 3
/** Base backoff between SEEN-flag push retries, scaled by attempt number (2s, then 4s). */
private const val SEEN_FLAG_RETRY_BACKOFF_MS = 2_000L
/** Message id is "<accountId>:<uid>"; the uid is the trailing segment. */
private fun uidOf(id: String): String = id.substringAfterLast(':')
private fun snippetOf(body: String): String =
body.replace(Regex("<[^>]*>"), " ").replace(Regex("\\s+"), " ").trim().take(140)

Some files were not shown because too many files have changed in this diff Show More