Adds a debounced periodic draft save alongside the existing exit-time save (#177), so an in-progress compose survives a background-kill or crash without going through the explicit back gesture.
Prerequisite bug fix — session-stable draft id.draftId was a nullable val; saveOrDeleteDraft()'s id = draftId ?: UUID.randomUUID() minted a new id on every call. Harmless when it ran once at exit, but periodic autosave would insert a fresh duplicate draft row per tick. The persist id is now generated once (persistedDraftId, lazy) and reused for every save this session. A draftPersisted flag tracks whether a row exists, driving delete-on-empty and delete-on-send so an autosaved brand-new draft is never orphaned.
Debounced autosave. An init collector observes _state mapped to just the persisted fields (to/cc/bcc/subject/body/bodyHtml/attachments), distinctUntilChanged().drop(1).debounce(1500ms), then calls the shared autosaveDraft(). Coalesces rapid keystrokes into one write. From-account and transient UI flags are intentionally excluded (matches the ticket's field list). Mirrors the existing MailboxViewModel debounce pattern (@OptIn(FlowPreview::class), top-level private const window).
Immediate flush on backgrounding.ComposeScreen gains a LifecycleEventEffect(Lifecycle.Event.ON_STOP) { viewModel.flushDraft() }, mirroring the existing ON_RESUME effect, so the last keystrokes within the debounce window aren't lost if the process is killed while backgrounded.
Guards.autosaveDraft() mirrors onExit()'s if (!sending) guard and also checks navigated, so a debounce tick that lands after a send can't re-create a draft for the already-sent message. onExit()'s save-or-delete-on-back behavior is unchanged — autosave is additive.
Test plan
New MockK/TestScope VM tests (Turbine-style, using advanceUntilIdle/runCurrent virtual time):
rapid successive edits coalesce into a single debounced autosave
regression: repeated autosaves of a brand-new draft update a single row (assert saved[0].id == saved[1].id) — the draft-id-reuse fix
exiting saves immediately without waiting for the debounce window
does not autosave while a send is in flight (gated sendMessage holds sending = true)
sending after an autosave deletes the autosaved draft (no orphan)
Full local preflight, JDK 21, --max-workers=8, one at a time:
## Summary
Adds a debounced periodic draft save alongside the existing exit-time save (#177), so an in-progress compose survives a background-kill or crash without going through the explicit back gesture.
- **Prerequisite bug fix — session-stable draft id.** `draftId` was a nullable `val`; `saveOrDeleteDraft()`'s `id = draftId ?: UUID.randomUUID()` minted a **new** id on every call. Harmless when it ran once at exit, but periodic autosave would insert a fresh duplicate draft row per tick. The persist id is now generated once (`persistedDraftId`, lazy) and reused for every save this session. A `draftPersisted` flag tracks whether a row exists, driving delete-on-empty and delete-on-send so an autosaved brand-new draft is never orphaned.
- **Debounced autosave.** An `init` collector observes `_state` mapped to just the persisted fields (`to`/`cc`/`bcc`/`subject`/`body`/`bodyHtml`/`attachments`), `distinctUntilChanged().drop(1).debounce(1500ms)`, then calls the shared `autosaveDraft()`. Coalesces rapid keystrokes into one write. From-account and transient UI flags are intentionally excluded (matches the ticket's field list). Mirrors the existing `MailboxViewModel` debounce pattern (`@OptIn(FlowPreview::class)`, top-level `private const` window).
- **Immediate flush on backgrounding.** `ComposeScreen` gains a `LifecycleEventEffect(Lifecycle.Event.ON_STOP) { viewModel.flushDraft() }`, mirroring the existing `ON_RESUME` effect, so the last keystrokes within the debounce window aren't lost if the process is killed while backgrounded.
- **Guards.** `autosaveDraft()` mirrors `onExit()`'s `if (!sending)` guard and also checks `navigated`, so a debounce tick that lands after a send can't re-create a draft for the already-sent message. `onExit()`'s save-or-delete-on-back behavior is unchanged — autosave is additive.
## Test plan
New MockK/`TestScope` VM tests (Turbine-style, using `advanceUntilIdle`/`runCurrent` virtual time):
- [x] rapid successive edits coalesce into a single debounced autosave
- [x] **regression:** repeated autosaves of a brand-new draft update a single row (assert `saved[0].id == saved[1].id`) — the draft-id-reuse fix
- [x] exiting saves immediately without waiting for the debounce window
- [x] does not autosave while a send is in flight (gated `sendMessage` holds `sending = true`)
- [x] sending after an autosave deletes the autosaved draft (no orphan)
Full local preflight, JDK 21, `--max-workers=8`, one at a time:
- [x] `:app:assembleDebug`
- [x] `:app:testDebugUnitTest` (33/33 `ComposeViewModelTest`, incl. 5 new; 0 failures)
- [x] `:app:lintDebug`
- [x] `:app:ktlintCheck :app:detekt`
- [x] `:app:compileDebugAndroidTestKotlin` (ComposeScreen changed → confirms `ComposeScreenTest` still compiles)
No Room change. `RichTextEditor.kt` / the formatting toolbar were not touched.
Closes #177
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Summary
Adds a debounced periodic draft save alongside the existing exit-time save (#177), so an in-progress compose survives a background-kill or crash without going through the explicit back gesture.
draftIdwas a nullableval;saveOrDeleteDraft()'sid = draftId ?: UUID.randomUUID()minted a new id on every call. Harmless when it ran once at exit, but periodic autosave would insert a fresh duplicate draft row per tick. The persist id is now generated once (persistedDraftId, lazy) and reused for every save this session. AdraftPersistedflag tracks whether a row exists, driving delete-on-empty and delete-on-send so an autosaved brand-new draft is never orphaned.initcollector observes_statemapped to just the persisted fields (to/cc/bcc/subject/body/bodyHtml/attachments),distinctUntilChanged().drop(1).debounce(1500ms), then calls the sharedautosaveDraft(). Coalesces rapid keystrokes into one write. From-account and transient UI flags are intentionally excluded (matches the ticket's field list). Mirrors the existingMailboxViewModeldebounce pattern (@OptIn(FlowPreview::class), top-levelprivate constwindow).ComposeScreengains aLifecycleEventEffect(Lifecycle.Event.ON_STOP) { viewModel.flushDraft() }, mirroring the existingON_RESUMEeffect, so the last keystrokes within the debounce window aren't lost if the process is killed while backgrounded.autosaveDraft()mirrorsonExit()'sif (!sending)guard and also checksnavigated, so a debounce tick that lands after a send can't re-create a draft for the already-sent message.onExit()'s save-or-delete-on-back behavior is unchanged — autosave is additive.Test plan
New MockK/
TestScopeVM tests (Turbine-style, usingadvanceUntilIdle/runCurrentvirtual time):saved[0].id == saved[1].id) — the draft-id-reuse fixsendMessageholdssending = true)Full local preflight, JDK 21,
--max-workers=8, one at a time::app:assembleDebug:app:testDebugUnitTest(33/33ComposeViewModelTest, incl. 5 new; 0 failures):app:lintDebug:app:ktlintCheck :app:detekt:app:compileDebugAndroidTestKotlin(ComposeScreen changed → confirmsComposeScreenTeststill compiles)No Room change.
RichTextEditor.kt/ the formatting toolbar were not touched.Closes #177
🤖 Generated with Claude Code