mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-03 06:55:59 +00:00
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:
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user