mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-03 16:10:20 +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:
@@ -0,0 +1,256 @@
|
||||
//
|
||||
// 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
|
||||
@testable import ElementX
|
||||
import Foundation
|
||||
import Testing
|
||||
import UIKit
|
||||
|
||||
@MainActor
|
||||
struct PresenceServiceTests {
|
||||
let clientProxy: ClientProxyMock
|
||||
let appSettings: AppSettings
|
||||
let notificationCenter: NotificationCenter
|
||||
|
||||
let sendsSubject = PassthroughSubject<(presence: ClientProxyPresence, sendImmediately: Bool), Never>()
|
||||
|
||||
init() {
|
||||
clientProxy = ClientProxyMock(.init())
|
||||
appSettings = AppSettings.volatile()
|
||||
notificationCenter = NotificationCenter()
|
||||
|
||||
clientProxy.configurePresenceSendImmediatelyClosure = { [sendsSubject] presence, sendImmediately in
|
||||
sendsSubject.send((presence, sendImmediately))
|
||||
return .success(())
|
||||
}
|
||||
}
|
||||
|
||||
private func makeService(initialApplicationState: UIApplication.State) -> PresenceService {
|
||||
PresenceService(clientProxy: clientProxy,
|
||||
appSettings: appSettings,
|
||||
notificationCenter: notificationCenter,
|
||||
initialApplicationState: initialApplicationState)
|
||||
}
|
||||
|
||||
@Test
|
||||
func serviceSeededActiveWhileSharingReportsOnline() async throws {
|
||||
appSettings.sharePresence = true
|
||||
|
||||
let deferred = deferFulfillment(sendsSubject) { $0.presence == .online }
|
||||
let service = makeService(initialApplicationState: .active)
|
||||
let send = try await deferred.fulfill()
|
||||
|
||||
#expect(send.presence == .online)
|
||||
#expect(send.sendImmediately)
|
||||
#expect(clientProxy.configurePresenceSendImmediatelyReceivedInvocations.filter { $0.presence == .online }.count == 1)
|
||||
_ = service
|
||||
}
|
||||
|
||||
@Test
|
||||
func serviceSeededBackgroundedWhileSharingReportsUnavailable() async throws {
|
||||
appSettings.sharePresence = true
|
||||
|
||||
let deferred = deferFulfillment(sendsSubject) { $0.presence == .unavailable }
|
||||
let service = makeService(initialApplicationState: .background)
|
||||
let send = try await deferred.fulfill()
|
||||
|
||||
#expect(send.presence == .unavailable)
|
||||
#expect(send.sendImmediately)
|
||||
_ = service
|
||||
}
|
||||
|
||||
@Test
|
||||
func serviceSeededActiveWhileNotSharingReportsOffline() async throws {
|
||||
appSettings.sharePresence = false
|
||||
|
||||
let deferred = deferFulfillment(sendsSubject) { $0.presence == .offline }
|
||||
let service = makeService(initialApplicationState: .active)
|
||||
let send = try await deferred.fulfill()
|
||||
|
||||
#expect(send.presence == .offline)
|
||||
#expect(send.sendImmediately)
|
||||
_ = service
|
||||
}
|
||||
|
||||
@Test
|
||||
func becomingActiveNotificationWhileSharingReportsOnlineImmediately() async throws {
|
||||
appSettings.sharePresence = true
|
||||
let service = makeService(initialApplicationState: .inactive)
|
||||
|
||||
let deferred = deferFulfillment(sendsSubject) { $0.presence == .online }
|
||||
notificationCenter.post(name: UIApplication.didBecomeActiveNotification, object: nil)
|
||||
let send = try await deferred.fulfill()
|
||||
|
||||
#expect(send.presence == .online)
|
||||
#expect(send.sendImmediately)
|
||||
_ = service
|
||||
}
|
||||
|
||||
@Test
|
||||
func resigningActiveNotificationWhileSharingReportsUnavailableImmediately() async throws {
|
||||
appSettings.sharePresence = true
|
||||
let initialDeferred = deferFulfillment(sendsSubject) { $0.presence == .online }
|
||||
let service = makeService(initialApplicationState: .active)
|
||||
_ = try await initialDeferred.fulfill()
|
||||
|
||||
let deferred = deferFulfillment(sendsSubject) { $0.presence == .unavailable }
|
||||
notificationCenter.post(name: UIApplication.willResignActiveNotification, object: nil)
|
||||
let send = try await deferred.fulfill()
|
||||
|
||||
#expect(send.presence == .unavailable)
|
||||
#expect(send.sendImmediately)
|
||||
_ = service
|
||||
}
|
||||
|
||||
@Test
|
||||
func togglingSharingWhileActiveDrivesOnlineAndOffline() async throws {
|
||||
appSettings.sharePresence = false
|
||||
let initialDeferred = deferFulfillment(sendsSubject) { $0.presence == .offline }
|
||||
let service = makeService(initialApplicationState: .active)
|
||||
_ = try await initialDeferred.fulfill()
|
||||
|
||||
let onlineDeferred = deferFulfillment(sendsSubject) { $0.presence == .online }
|
||||
appSettings.sharePresence = true
|
||||
let online = try await onlineDeferred.fulfill()
|
||||
#expect(online.presence == .online)
|
||||
#expect(online.sendImmediately)
|
||||
|
||||
let offlineDeferred = deferFulfillment(sendsSubject) { $0.presence == .offline }
|
||||
appSettings.sharePresence = false
|
||||
let offline = try await offlineDeferred.fulfill()
|
||||
#expect(offline.presence == .offline)
|
||||
#expect(offline.sendImmediately)
|
||||
|
||||
_ = service
|
||||
}
|
||||
|
||||
@Test
|
||||
func repeatingTheSameStateSendsOnlyOnce() async throws {
|
||||
appSettings.sharePresence = true
|
||||
|
||||
let initialDeferred = deferFulfillment(sendsSubject) { $0.presence == .online }
|
||||
let service = makeService(initialApplicationState: .active)
|
||||
_ = try await initialDeferred.fulfill()
|
||||
await Task.yield()
|
||||
|
||||
notificationCenter.post(name: UIApplication.didBecomeActiveNotification, object: nil)
|
||||
notificationCenter.post(name: UIApplication.didBecomeActiveNotification, object: nil)
|
||||
await Task.yield()
|
||||
|
||||
#expect(clientProxy.configurePresenceSendImmediatelyReceivedInvocations.map(\.presence) == [.online])
|
||||
_ = service
|
||||
}
|
||||
|
||||
@Test
|
||||
func failedImmediateSendIsNotRetriedUntilTheDesiredPresenceChanges() async throws {
|
||||
appSettings.sharePresence = true
|
||||
clientProxy.configurePresenceSendImmediatelyClosure = { [sendsSubject] presence, sendImmediately in
|
||||
sendsSubject.send((presence, sendImmediately))
|
||||
return presence == .online ? .failure(.forbiddenAccess) : .success(())
|
||||
}
|
||||
|
||||
let initialDeferred = deferFulfillment(sendsSubject) { $0.presence == .online }
|
||||
let service = makeService(initialApplicationState: .active)
|
||||
_ = try await initialDeferred.fulfill()
|
||||
await Task.yield()
|
||||
|
||||
notificationCenter.post(name: UIApplication.didBecomeActiveNotification, object: nil)
|
||||
await Task.yield()
|
||||
#expect(clientProxy.configurePresenceSendImmediatelyReceivedInvocations.map(\.presence) == [.online])
|
||||
|
||||
let transitionDeferred = deferFulfillment(sendsSubject) { $0.presence == .unavailable }
|
||||
notificationCenter.post(name: UIApplication.willResignActiveNotification, object: nil)
|
||||
_ = try await transitionDeferred.fulfill()
|
||||
|
||||
#expect(clientProxy.configurePresenceSendImmediatelyReceivedInvocations.map(\.presence) == [.online, .unavailable])
|
||||
_ = service
|
||||
}
|
||||
|
||||
@Test
|
||||
func newerTransitionWinsWhenAnEarlierSendIsInFlight() async throws {
|
||||
appSettings.sharePresence = true
|
||||
|
||||
let onlineStarted = PassthroughSubject<Void, Never>()
|
||||
let gate = AsyncStream<Void>.makeStream()
|
||||
clientProxy.configurePresenceSendImmediatelyClosure = { [sendsSubject, onlineStarted] presence, sendImmediately in
|
||||
if presence == .online {
|
||||
onlineStarted.send(())
|
||||
var iterator = gate.stream.makeAsyncIterator()
|
||||
_ = await iterator.next()
|
||||
}
|
||||
|
||||
sendsSubject.send((presence, sendImmediately))
|
||||
return .success(())
|
||||
}
|
||||
|
||||
let onlineStartedDeferred = deferFulfillment(onlineStarted) { _ in true }
|
||||
let service = makeService(initialApplicationState: .active)
|
||||
try await onlineStartedDeferred.fulfill()
|
||||
|
||||
let unavailableDeferred = deferFulfillment(sendsSubject) { $0.presence == .unavailable }
|
||||
notificationCenter.post(name: UIApplication.willResignActiveNotification, object: nil)
|
||||
|
||||
gate.continuation.yield()
|
||||
gate.continuation.finish()
|
||||
try await unavailableDeferred.fulfill()
|
||||
|
||||
let applied = clientProxy.configurePresenceSendImmediatelyReceivedInvocations.map(\.presence)
|
||||
#expect(applied == [.online, .unavailable], "Expected in-order latest-wins drain, got \(applied)")
|
||||
_ = service
|
||||
}
|
||||
|
||||
@Test
|
||||
func deinitCancelsPendingSend() async throws {
|
||||
appSettings.sharePresence = true
|
||||
|
||||
let onlineStarted = AsyncStream.makeStream(of: Void.self)
|
||||
let onlineCancelled = AsyncStream.makeStream(of: Void.self)
|
||||
let onlineCompleted = AsyncStream.makeStream(of: Void.self)
|
||||
let gate = AsyncStream.makeStream(of: Void.self)
|
||||
let gateContinuation = gate.continuation
|
||||
let onlineCancelledContinuation = onlineCancelled.continuation
|
||||
let onlineCompletedContinuation = onlineCompleted.continuation
|
||||
|
||||
clientProxy.configurePresenceSendImmediatelyClosure = { presence, _ in
|
||||
guard presence == .online else {
|
||||
return .success(())
|
||||
}
|
||||
|
||||
onlineStarted.continuation.yield()
|
||||
let result: Result<Void, ClientProxyError> = await withTaskCancellationHandler {
|
||||
var iterator = gate.stream.makeAsyncIterator()
|
||||
_ = await iterator.next()
|
||||
return .success(())
|
||||
} onCancel: {
|
||||
gateContinuation.finish()
|
||||
onlineCancelledContinuation.yield()
|
||||
onlineCancelledContinuation.finish()
|
||||
}
|
||||
onlineCompletedContinuation.yield()
|
||||
onlineCompletedContinuation.finish()
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
let onlineStartedDeferred = deferFulfillment(onlineStarted.stream) { _ in true }
|
||||
var service: PresenceService? = makeService(initialApplicationState: .active)
|
||||
#expect(service != nil)
|
||||
try await onlineStartedDeferred.fulfill()
|
||||
|
||||
notificationCenter.post(name: UIApplication.willResignActiveNotification, object: nil)
|
||||
|
||||
let onlineCancelledDeferred = deferFulfillment(onlineCancelled.stream) { _ in true }
|
||||
let onlineCompletedDeferred = deferFulfillment(onlineCompleted.stream) { _ in true }
|
||||
service = nil
|
||||
try await onlineCancelledDeferred.fulfill()
|
||||
try await onlineCompletedDeferred.fulfill()
|
||||
|
||||
let applied = clientProxy.configurePresenceSendImmediatelyReceivedInvocations.map(\.presence)
|
||||
#expect(applied == [.online], "Expected deinit to cancel the queued unavailable send, got \(applied)")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user