feat(compose): inline images #201

Merged
JMR-dev merged 1 commits from feat-77-inline-images into main 2026-07-03 06:47:00 +00:00
JMR-dev commented 2026-07-03 06:34:19 +00:00 (Migrated from github.com)

Summary

Wires inline images through the whole compose → send pipeline.

⚠️ Migration version correction: the ticket says "Room 12→13", but the schema had advanced — the current version is 17, so this ships MIGRATION_17_18 and exports 18.json.

  • 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 ComposeScreenTest taps without scrolling (the #73/#76 lesson). 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, ALTER TABLE outbox ADD COLUMN attachments TEXT NOT NULL DEFAULT '', following 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: true + contentId.

Reader-side cid: rendering is explicitly out of scope (follow-up), per the ticket.

Test plan

  • :app:assembleDebug — main + Room schema export (18.json matches the migration DDL byte-for-byte).
  • :app:testDebugUnitTest:
    • SmtpSenderTest (GreenMail): an inline image is sent as multipart/related with a Content-ID matching the HTML's cid:; the attachment tests still pass.
    • GraphSenderTest: an inline image is marked isInline with its contentId; a plain attachment carries neither.
    • OutgoingAttachmentMapperTest: an inline image's cid↔file pairing round-trips a draft save/reopen; an old draft JSON reads back as a plain attachment.
    • RichTextEditingTest: insertImage places the token + RichImage, shifts later spans, and round-trips its image through HTML.
  • :app:lintDebug
  • :app:ktlintCheck :app:detekt
  • :app:compileDebugAndroidTestKotlin — MigrationTest.migrate17To18_addsEmptyAttachmentsToOutbox plus the auto-discovered full-chain replay (runs on-device in CI).

Emulator E2E (including the migration tests) left to CI.

Closes #77

🤖 Generated with Claude Code

## Summary Wires inline images through the whole compose → send pipeline. > ⚠️ **Migration version correction:** the ticket says "Room 12→13", but the schema had advanced — the current version is **17**, so this ships **`MIGRATION_17_18`** and exports **`18.json`**. - **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 `ComposeScreenTest` taps without scrolling (the #73/#76 lesson). 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`, `ALTER TABLE outbox ADD COLUMN attachments TEXT NOT NULL DEFAULT ''`, following 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 `fileAttachment`s carry `isInline: true` + `contentId`. Reader-side `cid:` rendering is explicitly **out of scope** (follow-up), per the ticket. ## Test plan - [x] `:app:assembleDebug` — main + Room schema export (`18.json` matches the migration DDL byte-for-byte). - [x] `:app:testDebugUnitTest`: - `SmtpSenderTest` (GreenMail): an inline image is sent as `multipart/related` with a `Content-ID` matching the HTML's `cid:`; the attachment tests still pass. - `GraphSenderTest`: an inline image is marked `isInline` with its `contentId`; a plain attachment carries neither. - `OutgoingAttachmentMapperTest`: an inline image's cid↔file pairing round-trips a draft save/reopen; an old draft JSON reads back as a plain attachment. - `RichTextEditingTest`: `insertImage` places the token + `RichImage`, shifts later spans, and round-trips its image through HTML. - [x] `:app:lintDebug` - [x] `:app:ktlintCheck :app:detekt` - [x] `:app:compileDebugAndroidTestKotlin` — `MigrationTest.migrate17To18_addsEmptyAttachmentsToOutbox` plus the auto-discovered full-chain replay (runs on-device in CI). Emulator E2E (including the migration tests) left to CI. Closes #77 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Sign in to join this conversation.