Drafts are currently only saved on explicit exit: ComposeViewModel.onExit()
(ComposeViewModel.kt:227-235) calls saveOrDeleteDraft(), wired to both the back gesture
(BackHandler { viewModel.onExit() }) and the toolbar back icon in ComposeScreen.kt. Nothing
persists while the user is actively typing — every field setter (onToChange, onBodyChange,
etc.) only updates the in-memory _state. If the process is killed while composing without going
through that exit path (backgrounded and reaped by Android, or a crash), the in-progress draft is
lost entirely. This ticket adds a debounced periodic save alongside the existing exit-time save.
A real bug to fix as part of this, not just a design nuance:draftId
(ComposeViewModel.kt:67-68) is a private val, set once at construction from the nav arg and
never reassigned. saveOrDeleteDraft() does id = draftId ?: UUID.randomUUID().toString()
(~line 255) — for a brand-new compose session (draftId == null), that generates a new random
UUID on every call, not just the first. Today this is harmless because saveOrDeleteDraft()
only ever runs once, at exit. The moment periodic autosave calls it repeatedly during a single
session, this becomes a real bug: every debounce tick would insert a new, separate draft row
instead of updating the same one, leaving a trail of orphaned duplicate drafts. Fixing this
(capturing and reusing the id generated by the first save) is a prerequisite for this ticket, not
an optional nice-to-have.
Scope
Fix the draft-id stability issue above: the id used for a brand-new draft must be generated
once and reused for every subsequent save in the same ComposeViewModel session (e.g.
replace the private val draftId with a lazily-initialized, session-stable id, or capture
the generated id back into a mutable field the first time saveOrDeleteDraft() runs).
Add a debounced autosave: observe _state for changes to the fields saveOrDeleteDraft()
already keys off (to/cc/bcc/subject/body/bodyHtml/attachments) and call saveOrDeleteDraft() after a debounce window (e.g. 1-2s) of no further changes, via viewModelScope — coalescing rapid keystrokes into one write rather than saving on every
character.
Add an immediate (non-debounced) flush on backgrounding: a debounce window means the very
latest keystrokes right before the user switches away could still be lost if the app is
killed before the debounce fires. Flush the pending save on Lifecycle.Event.ON_STOP in ComposeScreen.kt, mirroring the existing LifecycleEventEffect(Lifecycle.Event.ON_RESUME)
pattern already used there (~line 103).
Leave onExit()'s save-or-delete-on-back behavior unchanged — periodic autosave is
additive, not a replacement; exiting should still save/delete immediately regardless of the
debounce state.
Don't autosave a message that's already sending (mirror the existing if (!_state.value.sending) saveOrDeleteDraft() guard in onExit()).
Acceptance criteria
Typing in compose, without exiting, results in the draft being persisted to the DB after a short
idle period — verifiable by reopening the drafts list without having tapped back.
Repeated autosaves of a brand-new (previously-unsaved) draft update a single row, not one row
per autosave tick.
Backgrounding the app immediately after typing (within the debounce window) still results in the
latest content being saved.
Existing exit-time save/delete behavior, and the "don't save mid-send" guard, are unchanged.
JVM tests (Turbine/TestScope, per this repo's existing coroutine-test conventions) cover: rapid
successive changes coalescing into one save, the draft-id-reuse fix specifically (regression test
for the bug described above), and that exiting doesn't wait for the debounce.
## Context
Drafts are currently only saved on explicit exit: `ComposeViewModel.onExit()`
(`ComposeViewModel.kt:227-235`) calls `saveOrDeleteDraft()`, wired to both the back gesture
(`BackHandler { viewModel.onExit() }`) and the toolbar back icon in `ComposeScreen.kt`. Nothing
persists while the user is actively typing — every field setter (`onToChange`, `onBodyChange`,
etc.) only updates the in-memory `_state`. If the process is killed while composing without going
through that exit path (backgrounded and reaped by Android, or a crash), the in-progress draft is
lost entirely. This ticket adds a debounced periodic save alongside the existing exit-time save.
**A real bug to fix as part of this, not just a design nuance:** `draftId`
(`ComposeViewModel.kt:67-68`) is a `private val`, set once at construction from the nav arg and
never reassigned. `saveOrDeleteDraft()` does `id = draftId ?: UUID.randomUUID().toString()`
(~line 255) — for a brand-new compose session (`draftId == null`), that generates a **new random
UUID on every call**, not just the first. Today this is harmless because `saveOrDeleteDraft()`
only ever runs once, at exit. The moment periodic autosave calls it repeatedly during a single
session, this becomes a real bug: every debounce tick would insert a **new, separate draft row**
instead of updating the same one, leaving a trail of orphaned duplicate drafts. Fixing this
(capturing and reusing the id generated by the first save) is a prerequisite for this ticket, not
an optional nice-to-have.
## Scope
- [ ] Fix the draft-id stability issue above: the id used for a brand-new draft must be generated
once and reused for every subsequent save in the same `ComposeViewModel` session (e.g.
replace the `private val draftId` with a lazily-initialized, session-stable id, or capture
the generated id back into a mutable field the first time `saveOrDeleteDraft()` runs).
- [ ] Add a debounced autosave: observe `_state` for changes to the fields `saveOrDeleteDraft()`
already keys off (`to`/`cc`/`bcc`/`subject`/`body`/`bodyHtml`/`attachments`) and call
`saveOrDeleteDraft()` after a debounce window (e.g. 1-2s) of no further changes, via
`viewModelScope` — coalescing rapid keystrokes into one write rather than saving on every
character.
- [ ] Add an immediate (non-debounced) flush on backgrounding: a debounce window means the very
latest keystrokes right before the user switches away could still be lost if the app is
killed before the debounce fires. Flush the pending save on `Lifecycle.Event.ON_STOP` in
`ComposeScreen.kt`, mirroring the existing `LifecycleEventEffect(Lifecycle.Event.ON_RESUME)`
pattern already used there (~line 103).
- [ ] Leave `onExit()`'s save-or-delete-on-back behavior unchanged — periodic autosave is
additive, not a replacement; exiting should still save/delete immediately regardless of the
debounce state.
- [ ] Don't autosave a message that's already sending (mirror the existing
`if (!_state.value.sending) saveOrDeleteDraft()` guard in `onExit()`).
## Acceptance criteria
- Typing in compose, without exiting, results in the draft being persisted to the DB after a short
idle period — verifiable by reopening the drafts list without having tapped back.
- Repeated autosaves of a brand-new (previously-unsaved) draft update a single row, not one row
per autosave tick.
- Backgrounding the app immediately after typing (within the debounce window) still results in the
latest content being saved.
- Existing exit-time save/delete behavior, and the "don't save mid-send" guard, are unchanged.
- JVM tests (Turbine/`TestScope`, per this repo's existing coroutine-test conventions) cover: rapid
successive changes coalescing into one save, the draft-id-reuse fix specifically (regression test
for the bug described above), and that exiting doesn't wait for the debounce.
## Relevant files
- `ui/compose/ComposeViewModel.kt` (`draftId`, `saveOrDeleteDraft()`, `onExit()`),
`ui/compose/ComposeScreen.kt` (lifecycle effects, ~line 103).
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.
Context
Drafts are currently only saved on explicit exit:
ComposeViewModel.onExit()(
ComposeViewModel.kt:227-235) callssaveOrDeleteDraft(), wired to both the back gesture(
BackHandler { viewModel.onExit() }) and the toolbar back icon inComposeScreen.kt. Nothingpersists while the user is actively typing — every field setter (
onToChange,onBodyChange,etc.) only updates the in-memory
_state. If the process is killed while composing without goingthrough that exit path (backgrounded and reaped by Android, or a crash), the in-progress draft is
lost entirely. This ticket adds a debounced periodic save alongside the existing exit-time save.
A real bug to fix as part of this, not just a design nuance:
draftId(
ComposeViewModel.kt:67-68) is aprivate val, set once at construction from the nav arg andnever reassigned.
saveOrDeleteDraft()doesid = draftId ?: UUID.randomUUID().toString()(~line 255) — for a brand-new compose session (
draftId == null), that generates a new randomUUID on every call, not just the first. Today this is harmless because
saveOrDeleteDraft()only ever runs once, at exit. The moment periodic autosave calls it repeatedly during a single
session, this becomes a real bug: every debounce tick would insert a new, separate draft row
instead of updating the same one, leaving a trail of orphaned duplicate drafts. Fixing this
(capturing and reusing the id generated by the first save) is a prerequisite for this ticket, not
an optional nice-to-have.
Scope
once and reused for every subsequent save in the same
ComposeViewModelsession (e.g.replace the
private val draftIdwith a lazily-initialized, session-stable id, or capturethe generated id back into a mutable field the first time
saveOrDeleteDraft()runs)._statefor changes to the fieldssaveOrDeleteDraft()already keys off (
to/cc/bcc/subject/body/bodyHtml/attachments) and callsaveOrDeleteDraft()after a debounce window (e.g. 1-2s) of no further changes, viaviewModelScope— coalescing rapid keystrokes into one write rather than saving on everycharacter.
latest keystrokes right before the user switches away could still be lost if the app is
killed before the debounce fires. Flush the pending save on
Lifecycle.Event.ON_STOPinComposeScreen.kt, mirroring the existingLifecycleEventEffect(Lifecycle.Event.ON_RESUME)pattern already used there (~line 103).
onExit()'s save-or-delete-on-back behavior unchanged — periodic autosave isadditive, not a replacement; exiting should still save/delete immediately regardless of the
debounce state.
if (!_state.value.sending) saveOrDeleteDraft()guard inonExit()).Acceptance criteria
idle period — verifiable by reopening the drafts list without having tapped back.
per autosave tick.
latest content being saved.
TestScope, per this repo's existing coroutine-test conventions) cover: rapidsuccessive changes coalescing into one save, the draft-id-reuse fix specifically (regression test
for the bug described above), and that exiting doesn't wait for the debounce.
Relevant files
ui/compose/ComposeViewModel.kt(draftId,saveOrDeleteDraft(),onExit()),ui/compose/ComposeScreen.kt(lifecycle effects, ~line 103).