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
@@ -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()
}
}
}