Skip to content

feat: [SDK-5396] send a heartbeat every 30 minutes of foreground time - #2782

Merged
fadi-george merged 4 commits into
new-session-eventsfrom
fadi/SDK-5396-android-heartbeat
Oct 29, 2026
Merged

fadi-george merged 4 commits into
new-session-eventsfrom
fadi/SDK-5396-android-heartbeat

Conversation

@fadi-george

@fadi-george fadi-george commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

Description

One Line Summary

When the sessions API flag is on, send a duration-only session PATCH every 30 minutes of foreground time.

Details

Motivation

Part of the SDK Sessions stack (SDK-5396). Keeps long foreground sessions alive on the server's inactivity timer and bounds duration loss if the app crashes while foregrounded.

Scope

  • SessionService starts a heartbeat coroutine on focus for sessions whose usesSessionsApi is true, and cancels it on unfocus.
  • The interval counts foreground time only: a tick is due once the session's active duration (including the current focus, measured with elapsedRealtime) is 30 minutes past the last heartbeat. Background time doesn't count, and each tick's duration is always higher than the last, so unchanged durations are never sent.
  • Each tick enqueues an UpdateSessionOperation with the cumulative duration and no end_time. SessionQueuePolicy already folds unsent updates into the newest one and drops updates once the session is closed, so no queue changes.
  • The interval is hardcoded to 30 minutes; remote config is tracked in ANA-2562.
  • A new session restarts the interval. If the IDs needed for the request are missing, the tick is skipped and the timer waits for the next interval.
  • The heartbeat runs on the serial IO lane, the same as the focus handlers, so it doesn't race them. The timer lives in a small inner Heartbeat class to keep SessionService under detekt's function limit.
  • The last heartbeat's duration and time are persisted on SessionModel. An app killed while focused never adds its last focus to activeDuration, so the end on the next cold start uses the larger of activeDuration and the last heartbeat's duration, and the latest of the unfocus, focus, and heartbeat times as end_time. Otherwise the end could report less than the server already has.
  • Flag off: no timer.
  • The heartbeat loop catches failures and stops until the next focus, since the serial IO scope has no exception handler.
  • SessionQueuePolicy: an end that replaces a backed-off heartbeat keeps its retry count but not its retryAt, so the background job's 30 s wait isn't spent on a heartbeat's backoff.
  • The end op builder is generalized to updateSessionOperation(session, appId, activeDuration, endTime?) and shared with the heartbeat.

Testing

Unit testing

Added SessionServiceTests cases on a virtual clock: one heartbeat after 30 minutes, two after 60, background pauses the timer and only foreground time counts, new session restarts the interval, no timer on the legacy path, no heartbeat (and no busy loop) without a push subscription ID, cold start after a kill following a heartbeat ends with the heartbeat's duration and time, and a session end clears the last heartbeat. Heartbeat fields added to the persistence test.

spotlessCheck, detekt, and testDebugUnitTest (core) pass locally.

Manual testing

Not tested on device yet; end-to-end verification is planned in the Android Rollout & QA ticket (SDK-5405).

Affected code checklist

  • Notifications
    • Display
    • Open
    • Push Processing
    • Confirm Deliveries
  • Outcomes
  • Sessions
  • In-App Messaging
  • REST API requests
  • Public API changes

Checklist

Overview

  • I have filled out all REQUIRED sections above
  • PR does one thing
  • Any Public API changes are explained in the PR details and conform to existing APIs

Testing

  • I have included test coverage for these changes, or explained why they are not needed
  • All automated tests pass, or I explained why that is not possible
  • I have personally tested this on my device, or explained why that is not possible

Final pass

  • Code is as readable as possible.
  • I have reviewed this PR myself, ensuring it meets each checklist item

🤖 Generated with Claude Code

@fadi-george
fadi-george requested a review from a team as a code owner October 9, 2026 23:13
@github-actions

github-actions Bot commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

📊 Diff Coverage Report

Diff Coverage Report (Changed Lines Only)

Gate: aggregate coverage on changed executable lines must be ≥ 80% (JaCoCo line data for lines touched in the diff).

Changed Files Coverage

  • SessionQueuePolicy.kt: 1/1 touched executable lines (100.0%) (2 touched lines in diff)
  • SessionModel.kt: 6/6 touched executable lines (100.0%) (22 touched lines in diff)
  • SessionService.kt: 47/47 touched executable lines (100.0%) (91 touched lines in diff)

Overall (aggregate gate)

54/54 touched executable lines covered (100.0% — requires ≥ 80%)

📥 View workflow run

@abdulraqeeb33 abdulraqeeb33 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving.

Foreground time only, remaining time recomputed after wake, and a kill while focused ends at max(activeDuration, lastHeartbeatDuration). This depends on #2780 and #2781 holding creates and ends that are missing ids. Those two still drop them.

One nit: lastHeartbeatDuration is written before the enqueue. If the ids are missing, the tick is skipped and the next try waits another 30 minutes. A later successful tick still sends the cumulative duration, so this delays the update rather than losing it.

@fadi-george
fadi-george removed this pull request from stack #2776 October 16, 2026 00:26
@fadi-george
fadi-george added this pull request to stack #2798 October 16, 2026 00:29
Base automatically changed from fadi/SDK-5390-android-session-end to new-session-events October 27, 2026 21:44
While a sessions API session is focused, enqueue a duration-only
UpdateSessionOperation every 30 minutes of active time. The timer stops on
unfocus and resumes on focus, so background time doesn't count. Flag off
starts no timer.

The last heartbeat's duration and time are persisted on the session, so an
end after a kill while focused never reports less than the server already has.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
fadi-george and others added 3 commits October 28, 2026 10:46
A wall-clock jump saved on a heartbeat could move the session end past a
later unfocus. Clear the heartbeat time on unfocus so it only counts for
a session killed in the foreground.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nd by a heartbeat's backoff

- Catch failures in the heartbeat loop, since the serial IO scope has no
  exception handler; the heartbeat stops until the next focus.
- An end that replaces a backed-off heartbeat keeps its retry count but
  not its backoff, so it can still send while the background job waits.
- Share the focus-duration math between the heartbeat and unfocus, and
  clarify that lastHeartbeatDuration is set even when nothing was sent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A wall-clock jump mid-focus could otherwise move a killed session's end
past its foreground time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@fadi-george
fadi-george merged commit fe84a54 into new-session-events Oct 29, 2026
5 checks passed
@fadi-george
fadi-george deleted the fadi/SDK-5396-android-heartbeat branch October 29, 2026 00:48
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