Skip to content

feat(android): survive backgrounding — reconnect on resume + optional foreground service#117

Merged
krazyjakee merged 2 commits into
masterfrom
claude/android-background-connection-j8nm5l
Jun 11, 2026
Merged

feat(android): survive backgrounding — reconnect on resume + optional foreground service#117
krazyjakee merged 2 commits into
masterfrom
claude/android-background-connection-j8nm5l

Conversation

@krazyjakee

Copy link
Copy Markdown
Contributor

The app lost its gateway connection whenever Android froze the process
(cached-app freezer / Doze): heartbeats stopped, sockets died silently,
the automatic reconnect budget burned out while frozen, and nothing
recovered on refocus. Notifications also never showed on Android 13+
because POST_NOTIFICATIONS was never declared or requested.

accordkit:

  • GatewaySocket.ensureConnected(): revives a dead socket with a fresh
    backoff budget (resuming the session when one is held), and probes a
    nominally-connected socket with an out-of-band heartbeat, force-closing
    it into the reconnect path when the ACK never arrives. Forwarded on
    AccordClient; covered by six new gateway tests.

app:

  • AppLifecycleListener in MainWindow calls AccordAuth.ensureConnectedAll()
    on resume, so every server connection is verified/revived on refocus.
  • A re-READY (fresh identify gets no event replay) now refetches the
    history of every open message pane so panes catch up on what was missed.
  • POST_NOTIFICATIONS declared + requested at startup (Android 13+).

background connection (new, opt-in setting):

  • BackgroundConnectionService: a dataSync foreground service that keeps
    the process out of the cached-app freezer while backgrounded, so the
    gateway stays connected and mention notifications keep firing. Driven
    by a new 'Background connection' settings toggle (Android only) via a
    Riverpod controller and a small platform channel in MainActivity.

Partially addresses #81 (the app-alive half; closed-app push delivery
remains a server-side follow-up).

https://claude.ai/code/session_01DG54V8d6B9scQEvcU168RQ

claude added 2 commits June 11, 2026 19:26
… foreground service

The app lost its gateway connection whenever Android froze the process
(cached-app freezer / Doze): heartbeats stopped, sockets died silently,
the automatic reconnect budget burned out while frozen, and nothing
recovered on refocus. Notifications also never showed on Android 13+
because POST_NOTIFICATIONS was never declared or requested.

accordkit:
- GatewaySocket.ensureConnected(): revives a dead socket with a fresh
  backoff budget (resuming the session when one is held), and probes a
  nominally-connected socket with an out-of-band heartbeat, force-closing
  it into the reconnect path when the ACK never arrives. Forwarded on
  AccordClient; covered by six new gateway tests.

app:
- AppLifecycleListener in MainWindow calls AccordAuth.ensureConnectedAll()
  on resume, so every server connection is verified/revived on refocus.
- A re-READY (fresh identify gets no event replay) now refetches the
  history of every open message pane so panes catch up on what was missed.
- POST_NOTIFICATIONS declared + requested at startup (Android 13+).

background connection (new, opt-in setting):
- BackgroundConnectionService: a dataSync foreground service that keeps
  the process out of the cached-app freezer while backgrounded, so the
  gateway stays connected and mention notifications keep firing. Driven
  by a new 'Background connection' settings toggle (Android only) via a
  Riverpod controller and a small platform channel in MainActivity.

Partially addresses #81 (the app-alive half; closed-app push delivery
remains a server-side follow-up).

https://claude.ai/code/session_01DG54V8d6B9scQEvcU168RQ
…dd tests

Four correctness issues found in code review:

1. _probeLiveness() race with heartbeat timer (HIGH): the probe reset
   _heartbeatAckReceived before awaiting probeTimeout; if the heartbeat
   timer fired in that window it would see false and close a live
   connection before the probe ACK arrived. Fix: stop the heartbeat
   timer at probe start; restart it only if the connection is confirmed
   alive. Also guard ensureConnected() to skip the probe when still
   mid-handshake (connecting/resuming) — sending an OOB heartbeat
   before IDENTIFY/RESUME completes is undefined behaviour.

2. unawaited future (LOW): ensureConnected() discarded the Future
   returned by _probeLiveness() without unawaited().

3. BackgroundConnectionService START_STICKY → START_NOT_STICKY (MEDIUM):
   START_STICKY told Android to auto-restart the foreground service if
   the process was killed, but without the Flutter engine running there
   is no gateway to keep alive. START_NOT_STICKY matches the intent: the
   Riverpod controller re-starts the service when the user returns.

4. reload() missing isLoadingOlder reset (LOW): a concurrent loadOlder
   could complete after reload and overwrite the fresh page with stale
   prepended data. Setting isLoadingOlder=false in reload() lets loadOlder
   see the invalidation.

Tests: add 'is a no-op when the handshake is still in progress' and
'heartbeat timer does not race with probe during ack window'.

https://claude.ai/code/session_01FKMGHWiXCR9ZtWpoH6J9jR

Copy link
Copy Markdown
Contributor Author

Code Review — PR #117

Reviewed all 19 changed files. Four issues found and fixed in commit cb23c6d. Ranked by severity:


🔴 HIGH — _probeLiveness() race with heartbeat timer

File: packages/accordkit/lib/src/gateway/gateway_socket.dart

The probe reset _heartbeatAckReceived = false and then await-ed probeTimeout (5 s). If the heartbeat timer happened to fire in that window it would see false, conclude the connection was stale, and call _conn?.close(4000, 'heartbeat timeout') — killing a live connection before the probe ACK had a chance to arrive. With a typical heartbeat interval of 30 s this had a ~1-in-6 chance of triggering every time the user foregrounded the app.

Fix: _stopHeartbeat() at the top of _probeLiveness(), then _startHeartbeat() in the success branch (ACK received) to resume normal heartbeating.


🟠 MEDIUM — ensureConnected() could probe mid-handshake

File: packages/accordkit/lib/src/gateway/gateway_socket.dart

When _state was connecting or resuming, ensureConnected() fell through to _probeLiveness() and sent an out-of-band heartbeat opcode while the server was still waiting for IDENTIFY or RESUME. This is undefined behaviour against the Accord gateway.

Fix: Added guard if (_state != GatewayState.connected) return; — if a handshake is already in flight, let it complete.


🟠 MEDIUM — BackgroundConnectionService used START_STICKY

File: android/app/src/main/kotlin/com/daccord_projects/daccord/BackgroundConnectionService.kt

START_STICKY tells Android to auto-restart the service if the OS kills the process. But the service holds no logic of its own — its only job is to exempt the running Dart isolate from the cached-app freezer. If the process is killed, there is no Flutter engine and no gateway, so an auto-restarted service would hold a foreground notification slot without doing anything useful.

Fix: Changed to START_NOT_STICKY. The Riverpod BackgroundConnectionController re-starts the service when the user brings the app back.


🟡 LOW — reload() didn't reset isLoadingOlder

File: lib/features/messaging/controllers/accord_messages.dart

If loadOlder() was in flight when a re-READY triggered reload(), both futures would race to write state. loadOlder captures a snapshot of current before its REST call; if reload completed first (replacing state with fresh messages), loadOlder would then prepend its stale page on top.

Fix: reload() now sets isLoadingOlder = false before calling _load, signalling any in-flight loadOlder that its result is superseded.


🟡 LOW — unawaited future not explicit

File: packages/accordkit/lib/src/gateway/gateway_socket.dart

ensureConnected() discarded the Future<void> returned by _probeLiveness() without unawaited(). Fixed alongside the guard above.


Tests added (2 new cases)

  • is a no-op when the handshake is still in progress — calls ensureConnected() while _state == connecting and verifies no probe heartbeat is sent and no second connection is opened.
  • heartbeat timer does not race with probe during ack window — starts a socket with a HELLO (so the heartbeat timer is active), runs a gated probe, delivers an ACK, and verifies the connection remains intact (i.e., the timer did not fire during the probe window and close the socket).

No structural issues, no files over 1000 lines, no 3D model changes.


Generated by Claude Code

@krazyjakee krazyjakee merged commit 86a6557 into master Jun 11, 2026
2 checks passed
@krazyjakee krazyjakee deleted the claude/android-background-connection-j8nm5l branch June 11, 2026 19:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants