Skip to content

Send text messages by worker - #6720

Open
mahibi wants to merge 6 commits into
masterfrom
sendMessageWorker
Open

mahibi wants to merge 6 commits into
masterfrom
sendMessageWorker

Conversation

@mahibi

@mahibi mahibi commented Sep 18, 2026

Copy link
Copy Markdown
Collaborator

What changed (4 commits on this branch):

  1. 1fc75db — Text messages were sent from a plain viewModelScope coroutine tied to ChatActivity's ViewModel. If you left the chat while offline, that coroutine (and
    the only reconnect-listener that retried sending) died with it, so the message never went out. Added SendMessageWorker, a CoroutineWorker enqueued via WorkManager
    and chained per conversation (ExistingWorkPolicy.APPEND_OR_REPLACE), which survives leaving the chat, backgrounding, and process death, and retries with backoff.
    Both the normal send path and the manual "resend failed message" action now go through it.
  2. d4377ea — Deleting a message (via the message actions sheet) while it was still queued offline didn't stop the already-enqueued worker; it still posted the message
    once connectivity returned. The worker now re-checks that the temporary message row still exists at the start of every attempt (including retries) and skips the
    send if it's gone.
  3. 0858d09 — Editing a still-queued offline message only updated the local temp-message row; the worker kept sending the original text it had captured into
    WorkManager's input data at enqueue time. The worker now reads the message text and other send parameters fresh from that same row at send time instead of from stale
    captured data, so edits (and everything else already persisted on that row) are always picked up.
  4. d07bdd6 — Sending several messages in quick succession could deliver them to the server out of order (visible as a mismatch between mobile and web client ordering,
    until the periodic resync corrected it), because each send's local DB write and worker-enqueue ran as an independent, unordered coroutine. A Mutex now serializes
    that step per conversation so sends reach the server in the order they were actually sent.

Suggested manual test steps:

  • Offline send survives leaving the chat: Turn off network. Send a message. Immediately leave the chat (back out of ChatActivity, don't just background it). Turn
    network back on. Reopen the chat — the message should now show as sent (not stuck pending/failed).
  • Delete while offline: Turn off network, send a message, long-press it → Delete. Turn network back on. Confirm it never appears on the server side (e.g. check via web
    client) — it should not have been sent.
  • Edit while offline: Turn off network, send a message, long-press it → Edit, change the text, submit the edit. Turn network back on. Confirm the message that arrives
    on the server has the edited text, not the original.
  • Ordering under rapid sends: With a stable connection, type and send several short messages as fast as possible in a row. Compare the order they appear in on this
    device vs. a second client (web or another device) — they should match with no reordering.
  • Regression check: normal online sending, editing, deleting, and resending should behave exactly as before.

🏁 Checklist

  • ⛑️ Tests (unit and/or integration) are included or not needed
  • 🔖 Capability is checked or not needed
  • 🔙 Backport requests are created or not needed: /backport to stable-xx.x
  • 📅 Milestone is set
  • 🌸 PR title is meaningful (if it should be in the changelog: is it meaningful to users?)

🤖 AI (if applicable)

  • The content of this PR was partly or fully generated using AI

Text messages were sent from a plain viewModelScope coroutine tied to
ChatActivity's ViewModel. If the network was down and the user left the
chat before it recovered, the coroutine (and the only listener that
retried on reconnect) was destroyed with it, leaving the message stuck
locally with nothing left to retry it. SendMessageWorker now performs
the send, chained per conversation via WorkManager so it keeps running
independently of the chat screen and retries transient failures with
backoff. The manual "resend" action now goes through the same worker.

Assisted-by: Claude Code:claude-sonnet-5

Signed-off-by: Marcel Hibbe <dev@mhibbe.de>
SendMessageWorker had no way of knowing a temporary message was
deleted (via the message actions sheet) after it was enqueued but
before connectivity allowed it to actually run, so it still posted
the message to the server. Every attempt now re-checks that the
temporary message still exists before sending, and skips the send
if it's gone.

Assisted-by: Claude Code:claude-sonnet-5

Signed-off-by: Marcel Hibbe <dev@mhibbe.de>
… time

Editing a still-queued (offline) message only updated its local temp
message row (editTempChatMessage); SendMessageWorker kept sending the
original text it had captured into WorkManager's input data at enqueue
time, so the edit was silently lost once connectivity returned.

The worker now reads the message text, and the other send parameters
that are already persisted on that same row (account id, room token,
display name, reply-to, silent flag), fresh at send time instead of
from WorkManager's input data. This also removes the redundant copies
of that data from both enqueue() call sites.

addTemporaryMessage() now explicitly emits a success result so callers
don't have to rely on "no emission means success"; MessageInputViewModel
enqueues the worker only after that emission confirms the temporary
message row actually exists, closing a race where the worker could run
before the row was written and mistake that for a deleted message.

Assisted-by: Claude Code:claude-sonnet-5

Signed-off-by: Marcel Hibbe <dev@mhibbe.de>
sendChatMessage() launched an independent coroutine per call, each
awaiting its own local temporary-message DB write before enqueueing
SendMessageWorker. Those writes weren't guaranteed to finish in the
order they were started, so messages sent in quick succession could
reach SendMessageWorker's per-conversation queue - and therefore the
server - out of order, until a later resync corrected it.

A Mutex now serializes the insert-then-enqueue step per conversation,
held across the whole step rather than just the enqueue call, so one
send fully completes locally before the next queued send is allowed
to start its own DB write.

Assisted-by: Claude Code:claude-sonnet-5

Signed-off-by: Marcel Hibbe <dev@mhibbe.de>
@mahibi mahibi added this to the 25.1.0 milestone Sep 18, 2026
@mahibi mahibi self-assigned this Sep 18, 2026
@mahibi mahibi added the 3. to review Waiting for reviews label Sep 18, 2026
Adds unit tests for the two OfflineFirstChatRepository behaviors this
branch changed: addTemporaryMessage now emits Result.success on the
happy path (it previously emitted nothing there), and markMessageForResend
only resets a temp message's local status back to PENDING - it no longer
sends anything itself, since that's SendMessageWorker's job now.

Assisted-by: Claude Code:claude-sonnet-5

Signed-off-by: Marcel Hibbe <dev@mhibbe.de>
…early

addTemporaryMessage() wrapped its emit() calls in a single broad
catch (e: Exception) block:

    try {
        ...
        emit(Result.success(...))
    } catch (e: Exception) {
        emit(Result.failure(e))
    }

Cancellation in coroutines works by throwing a CancellationException at
whatever line is currently suspended, and relies on every catch block
along the way letting it continue upward unchanged so the cancellation
actually reaches the top and the coroutine stops. A caller that stops
collecting after one value - e.g. Flow.first()/take(1) - does exactly
this: the moment it receives a value from emit(), it throws a
cancellation-style exception right back into that emit() call to signal
"got it, stop sending".

Because CancellationException is still a subtype of Exception on the
JVM, the catch block above did not distinguish "the collector is done
listening" from "something actually failed". It caught that
cancellation the same way as a real failure and tried to recover by
calling emit(Result.failure(e)) - except the collector had already
disconnected, so that second emit() is illegal and crashed with
"Flow exception transparency is violated: emission attempt ... after
a previous emit() has thrown".

This was not just a test artifact: the exact same crash could happen in
production if MessageInputViewModel's coroutine (in
sendChatMessage()) gets cancelled - e.g. ChatActivity/its ViewModel
being cleared - at the exact moment it is suspended on this emit()
call.

Fixed by catching CancellationException first and rethrowing it
unchanged, before the generic catch (e: Exception) gets a chance to
treat it as a failure:

    } catch (e: CancellationException) {
        throw e
    } catch (e: Exception) {
        emit(Result.failure(e))
    }

The generic catch now only ever sees genuine failures (e.g. the DB
write itself throwing), and a collector disconnecting early is once
again just allowed to finish cancelling, the way structured concurrency
expects.

Added a regression test that collects addTemporaryMessage() with
first() directly, reproducing the exact crash scenario and confirming
it now returns the successful result cleanly instead of throwing.

Two other methods in this file (editChatMessage, editTempChatMessage)
have the identical emit()-inside-try/catch(Exception) shape and are
theoretically exposed to the same issue, but are pre-existing code
untouched by this branch - left alone for now, not fixed here.

Assisted-by: Claude Code:claude-sonnet-5

Signed-off-by: Marcel Hibbe <dev@mhibbe.de>
@mahibi
mahibi marked this pull request as ready for review September 18, 2026 11:35
@github-actions

Copy link
Copy Markdown
Contributor

APK file: https://github.com/nextcloud/talk-android/actions/runs/35340361411/artifacts/10544343537
To test this change/fix you can simply download above APK file and install and test it in parallel to your existing Nextcloud app.
qrcode (please click on link to get QR code displayed)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

3. to review Waiting for reviews AI assisted

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Worker to send temporary messages

1 participant