Compose: debounced periodic draft autosave (not just on exit) #177

Closed
opened 2026-07-02 22:41:49 +00:00 by JMR-dev · 0 comments
JMR-dev commented 2026-07-02 22:41:49 +00:00 (Migrated from github.com)

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).
## 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).
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: JMR-dev/LibreMail#177