Skip to content

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

Open
fadi-george wants to merge 13 commits into
fadi/SDK-5390-android-session-endfrom
fadi/SDK-5396-android-heartbeat
Open

fadi-george wants to merge 13 commits into
fadi/SDK-5390-android-session-endfrom
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. Based on #2781.

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. The existing coalescing folds unsent heartbeats together and discards them once the end is queued, 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 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 end op builder is generalized to toUpdateOperation(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, server session ID once created, 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 new session 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 and others added 12 commits October 9, 2026 13:53
…tion queue

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…return

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ard updates after in-flight end or for orphaned sessions

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… order

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…he session executor

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ssion pins in OperationRepo, drop unsendable ops under IV

- Session ops drop after 5 failed attempts the backend answered. The count is persisted, and network errors don't count, so offline still retries until reconnect.
- OperationRepo writes each ID translation it applies into the current session's pinned IDs and server session ID, replacing SessionService's PinnedLocalIdTranslator.
- Under required identity verification, session ops for a local OneSignal ID that no identified login will create are discarded at enqueue and dropped with the anonymous purge.
- CreateSessionOperation and UpdateSessionOperation share a SessionOperation base.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…cope session subscription translation, join pin write

- Coalesced updates keep the highest failedAttempts, so a session that keeps enqueueing updates still reaches the attempt limit.
- The session's pinned subscription ID is only translated when the same response translates its pinned OneSignal ID, since login reuses a local push subscription for the new user.
- The pin write is joined again so IDs are saved before a successful create is removed from the store, and it is skipped before the first session starts.
- LoginUserFromSubscriptionOperation counts as a login that will create the user under identity verification.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… race on queue order

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When the session uses the sessions API, enqueue a CreateSessionOperation
with the start time and pinned IDs. Continuing within the session timeout
sends none, and the legacy start path is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When the session uses the sessions API, enqueue an end UpdateSessionOperation
with the backgrounded-at time and final duration. In the background job, flush
and wait for it with a timeout so Android 15+ doesn't block the request after
the job finishes. Flag off leaves the legacy end unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
While a sessions API session is focused, enqueue a duration-only
UpdateSessionOperation every 30 minutes of active time. The timer is
cancelled on unfocus and resumes on focus, so background time doesn't
count. Flag off starts no timer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@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

  • SessionModel.kt: 6/6 touched executable lines (100.0%) (19 touched lines in diff)
  • SessionService.kt: 32/32 touched executable lines (100.0%) (65 touched lines in diff)

Overall (aggregate gate)

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

📥 View workflow run

…last heartbeat

An app killed while focused never adds its last focus to activeDuration, so
the end on the next cold start could report less duration than a heartbeat
already sent. Persist the last heartbeat's duration and time on the session
and use them as a floor for the end's duration and end time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@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.

continue
}
Logging.debug("SessionService: heartbeat with activeDuration: $duration")
session.lastHeartbeatDuration = duration

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.

This advances lastHeartbeatDuration even when toUpdateOperation returns null. A missing id then waits another full interval. Set it only after the update is enqueued, or leave it unchanged so the next tick retries.

@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
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