Report user presence on app lifecycle transitions (#5838)

* Report user presence on app lifecycle transitions

Adds a PresenceReporter driven by the app lifecycle handlers and the sharePresence setting: online when foreground-active and sharing, unavailable when backgrounded and sharing, offline when not sharing. Sends are immediate, deduped, and serialized latest-wins. Background app refresh and the NSE seed the process presence to offline so their sync requests carry no presence side effect. Presence rides on the SDK's process-global state, so every sync path is covered without touching sync lifecycle.

Signed-off-by: Jared L <48422312+lhjt@users.noreply.github.com>

* Add presence reporter tests

Pins the presence reporter invariants: the never-online sweep with sharing off, an immediate online send on foreground-active, dedupe of repeated states, latest-wins ordering of serialized sends, and the NSE offline seed.

Signed-off-by: Jared L <48422312+lhjt@users.noreply.github.com>

* wip: address review comments

Signed-off-by: Jared L <48422312+lhjt@users.noreply.github.com>

* refactor: remove obsolete presence heartbeat

Remove the shared activity snapshot, heartbeat refresh, and notification-extension freshness policy while retaining the session-scoped presence service and its serialized lifecycle reporting. Restore the NSE's intermediate one-time offline seed so the cleanup remains buildable ahead of the final sync-presence correction.

Signed-off-by: Jared L <48422312+lhjt@users.noreply.github.com>

* fix: make notification extension presence-neutral

Configure the built NSE client with offline sync presence without sending an immediate presence update. Keep notification setup non-fatal when presence configuration fails and remove the temporary production test seam.

Signed-off-by: Jared L <48422312+lhjt@users.noreply.github.com>

* chore: restore application lifecycle logging

Restore the pre-existing foreground lifecycle diagnostics that were removed during the presence refactor. Keep presence reporting session-scoped and clarify that background refresh configures the client's sync presence without an immediate update.

Signed-off-by: Jared L <48422312+lhjt@users.noreply.github.com>

---------

Signed-off-by: Jared L <48422312+lhjt@users.noreply.github.com>
This commit is contained in:
Jared L
2026-07-23 00:23:36 +10:00
committed by GitHub
parent e98a19eb3f
commit cff46137b9
10 changed files with 514 additions and 0 deletions
@@ -1285,6 +1285,10 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg
return
}
// State-only (no HTTP): configure the background-refresh sync to carry set_presence=offline so it never
// reuses the client's foreground sync presence and marks the user online or idle.
_ = await userSession.clientProxy.configurePresence(.offline, sendImmediately: false)
await resumeClientServices()
// Be a good citizen, run for a max of 10 SS responses or 10 seconds
@@ -26,6 +26,8 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
private let navigationTabCoordinator: NavigationTabCoordinator<HomeTab>
private let appLockService: AppLockServiceProtocol
private let flowParameters: CommonFlowParameters
// periphery:ignore - retaining purpose
private let presenceService: PresenceService
private var userSession: UserSessionProtocol {
flowParameters.userSession
@@ -78,6 +80,8 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
self.navigationRootCoordinator = navigationRootCoordinator
self.appLockService = appLockService
self.flowParameters = flowParameters
presenceService = PresenceService(clientProxy: flowParameters.userSession.clientProxy,
appSettings: flowParameters.appSettings)
navigationTabCoordinator = NavigationTabCoordinator()
navigationRootCoordinator.setRootCoordinator(navigationTabCoordinator)
@@ -167,5 +167,7 @@ extension ClientProxyMock {
underlyingMaxMediaUploadSize = .success(configuration.maxMediaUploadSize)
storeSizesReturnValue = .success(.init(cryptoStore: 1, stateStore: 9, eventCacheStore: 8, mediaStore: 6))
configurePresenceSendImmediatelyReturnValue = .success(())
}
}
@@ -4242,6 +4242,48 @@ nonisolated class ClientProxyMock: ClientProxyProtocol, @unchecked Sendable {
return setHideInviteAvatarsReturnValue
}
}
//MARK: - configurePresence
private let configurePresenceSendImmediatelyCallsCountLock = NSLock()
private nonisolated(unsafe) var configurePresenceSendImmediatelyUnderlyingCallsCount = 0
var configurePresenceSendImmediatelyCallsCount: Int {
get { configurePresenceSendImmediatelyCallsCountLock.withLock { configurePresenceSendImmediatelyUnderlyingCallsCount } }
set { configurePresenceSendImmediatelyCallsCountLock.withLock { configurePresenceSendImmediatelyUnderlyingCallsCount = newValue } }
}
var configurePresenceSendImmediatelyCalled: Bool {
return configurePresenceSendImmediatelyCallsCount > 0
}
private let configurePresenceSendImmediatelyReceivedArgumentsLock = NSLock()
private nonisolated(unsafe) var configurePresenceSendImmediatelyUnderlyingReceivedArguments: (presence: ClientProxyPresence, sendImmediately: Bool)?
var configurePresenceSendImmediatelyReceivedArguments: (presence: ClientProxyPresence, sendImmediately: Bool)? {
get { configurePresenceSendImmediatelyReceivedArgumentsLock.withLock { configurePresenceSendImmediatelyUnderlyingReceivedArguments } }
set { configurePresenceSendImmediatelyReceivedArgumentsLock.withLock { configurePresenceSendImmediatelyUnderlyingReceivedArguments = newValue } }
}
private let configurePresenceSendImmediatelyReceivedInvocationsLock = NSLock()
private nonisolated(unsafe) var configurePresenceSendImmediatelyUnderlyingReceivedInvocations: [(presence: ClientProxyPresence, sendImmediately: Bool)] = []
var configurePresenceSendImmediatelyReceivedInvocations: [(presence: ClientProxyPresence, sendImmediately: Bool)] {
get { configurePresenceSendImmediatelyReceivedInvocationsLock.withLock { configurePresenceSendImmediatelyUnderlyingReceivedInvocations } }
set { configurePresenceSendImmediatelyReceivedInvocationsLock.withLock { configurePresenceSendImmediatelyUnderlyingReceivedInvocations = newValue } }
}
private let configurePresenceSendImmediatelyReturnValueLock = NSLock()
private nonisolated(unsafe) var configurePresenceSendImmediatelyUnderlyingReturnValue: Result<Void, ClientProxyError>!
var configurePresenceSendImmediatelyReturnValue: Result<Void, ClientProxyError>! {
get { configurePresenceSendImmediatelyReturnValueLock.withLock { configurePresenceSendImmediatelyUnderlyingReturnValue } }
set { configurePresenceSendImmediatelyReturnValueLock.withLock { configurePresenceSendImmediatelyUnderlyingReturnValue = newValue } }
}
nonisolated(unsafe) var configurePresenceSendImmediatelyClosure: ((ClientProxyPresence, Bool) async -> Result<Void, ClientProxyError>)?
@concurrent func configurePresence(_ presence: ClientProxyPresence, sendImmediately: Bool) async -> Result<Void, ClientProxyError> {
configurePresenceSendImmediatelyCallsCountLock.withLock { configurePresenceSendImmediatelyUnderlyingCallsCount += 1 }
configurePresenceSendImmediatelyReceivedArguments = (presence: presence, sendImmediately: sendImmediately)
configurePresenceSendImmediatelyReceivedInvocationsLock.withLock { configurePresenceSendImmediatelyUnderlyingReceivedInvocations.append((presence: presence, sendImmediately: sendImmediately)) }
if let configurePresenceSendImmediatelyClosure = configurePresenceSendImmediatelyClosure {
return await configurePresenceSendImmediatelyClosure(presence, sendImmediately)
} else {
return configurePresenceSendImmediatelyReturnValue
}
}
}
nonisolated class CompletionSuggestionServiceMock: CompletionSuggestionServiceProtocol, @unchecked Sendable {
var suggestionsPublisher: AnyPublisher<[SuggestionItem], Never> {
@@ -1026,6 +1026,18 @@ class ClientProxy: ClientProxyProtocol {
}
}
// MARK: Presence
func configurePresence(_ presence: ClientProxyPresence, sendImmediately: Bool) async -> Result<Void, ClientProxyError> {
do {
try await client.setPresence(presence: presence.rustValue, immediate: sendImmediately)
return .success(())
} catch {
MXLog.error("Failed setting presence with error: \(error)")
return .failure(.sdkError(error))
}
}
// MARK: - Private
private func setupSubscriptions(canSubscribeToUserProfile: Bool) async {
@@ -1585,6 +1597,19 @@ private extension TimelineMediaVisibility {
}
}
private extension ClientProxyPresence {
var rustValue: PresenceState {
switch self {
case .online:
.online
case .unavailable:
.unavailable
case .offline:
.offline
}
}
}
private extension CreateRoomAccessType {
var isEncrypted: Bool {
switch self {
@@ -29,6 +29,12 @@ enum ClientProxyLoadingState {
case notLoading
}
enum ClientProxyPresence: Equatable, Sendable {
case online
case unavailable
case offline
}
enum ClientProxyError: Error {
case sdkError(Error)
case forbiddenAccess
@@ -284,4 +290,10 @@ protocol ClientProxyProtocol: AnyObject {
func setTimelineMediaVisibility(_ value: TimelineMediaVisibility) async -> Result<Void, ClientProxyError>
func setHideInviteAvatars(_ value: Bool) async -> Result<Void, ClientProxyError>
// MARK: - Presence
/// Configures the client-owned presence used by future sync requests and shared with clones and notification children.
/// When `sendImmediately` is `true` this also asks the SDK to send a direct presence update.
func configurePresence(_ presence: ClientProxyPresence, sendImmediately: Bool) async -> Result<Void, ClientProxyError>
}
@@ -0,0 +1,147 @@
//
// Copyright 2026 Element Creations Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
// Please see LICENSE files in the repository root for full details.
//
import Combine
import Foundation
import UIKit
final class PresenceService {
private let clientProxy: ClientProxyProtocol
private let appSettings: AppSettings
private let notificationCenter: NotificationCenter
private var isForegroundActive: Bool
private var lastSentPresence: ClientProxyPresence?
private var pendingPresence: ClientProxyPresence?
private var sendTask: Task<Void, Never>?
private var cancellables = Set<AnyCancellable>()
init(clientProxy: ClientProxyProtocol,
appSettings: AppSettings,
notificationCenter: NotificationCenter = .default,
initialApplicationState: UIApplication.State = UIApplication.shared.applicationState) {
self.clientProxy = clientProxy
self.appSettings = appSettings
self.notificationCenter = notificationCenter
isForegroundActive = initialApplicationState == .active
observeApplicationState()
observeSharePresence()
reportCurrentState()
}
isolated deinit {
sendTask?.cancel()
cancellables.forEach { $0.cancel() }
}
private var desiredPresence: ClientProxyPresence {
switch (isForegroundActive, appSettings.sharePresence) {
case (true, true):
.online
case (true, false):
.offline
case (false, true):
.unavailable
case (false, false):
.offline
}
}
private func observeApplicationState() {
notificationCenter.publisher(for: UIApplication.willResignActiveNotification)
.sink { [weak self] _ in
self?.apply(applicationState: .inactive)
}
.store(in: &cancellables)
notificationCenter.publisher(for: UIApplication.didBecomeActiveNotification)
.sink { [weak self] _ in
self?.apply(applicationState: .active)
}
.store(in: &cancellables)
}
private func observeSharePresence() {
appSettings.sharePresencePublisher
.removeDuplicates()
.sink { [weak self] _ in
self?.reportCurrentState()
}
.store(in: &cancellables)
}
private func apply(applicationState: UIApplication.State) {
isForegroundActive = applicationState == .active
reportCurrentState()
}
private func reportCurrentState() {
let presence = desiredPresence
guard sendTask != nil || presence != lastSentPresence else {
return
}
guard pendingPresence != presence else {
return
}
pendingPresence = presence
startSendTaskIfNeeded()
}
private func startSendTaskIfNeeded() {
guard sendTask == nil else {
return
}
let clientProxy = clientProxy
sendTask = Task { [weak self, clientProxy] in
while !Task.isCancelled {
guard let presence = await self?.nextPendingPresence() else {
break
}
_ = await clientProxy.configurePresence(presence, sendImmediately: true)
guard !Task.isCancelled else {
break
}
await self?.finishSending(presence)
}
await self?.finishSendTask()
}
}
private func nextPendingPresence() -> ClientProxyPresence? {
guard let pendingPresence else {
return nil
}
self.pendingPresence = nil
return pendingPresence
}
private func finishSending(_ presence: ClientProxyPresence) {
lastSentPresence = presence
if pendingPresence == presence {
pendingPresence = nil
}
}
private func finishSendTask() {
sendTask = nil
if pendingPresence != nil {
startSendTaskIfNeeded()
}
}
}