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
+16
View File
@@ -869,6 +869,7 @@
8B1D5CE017EEC734CF5FE130 /* Encodable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 260004737C573A56FA01E86E /* Encodable.swift */; };
8B408C574E35E1C9B43A50CE /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 048A21188AB19349D026BECD /* PrivacyInfo.xcprivacy */; };
8B41D0357B91CD3B6F6A3BCA /* EmoteRoomTimelineItemContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE378083653EF0C9B5E9D580 /* EmoteRoomTimelineItemContent.swift */; };
8B6ED1393F94AEEFAE74C8BB /* PresenceServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7168FDF4B2B394142FEE2A76 /* PresenceServiceTests.swift */; };
8B76191B9DDD1AC90A6E3A35 /* MediaFileHandleProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEC1D382565A4E9CAC2F14EA /* MediaFileHandleProxy.swift */; };
8B9C2890B6DD160A0662861E /* AudioPlaybackSpeed.swift in Sources */ = {isa = PBXBuildFile; fileRef = F78B4E56DBFFD4A7A39D10F5 /* AudioPlaybackSpeed.swift */; };
8BC8EF6705A78946C1F22891 /* SoftLogoutScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71A7D4DDEEE5D2CA0C8D63CD /* SoftLogoutScreen.swift */; };
@@ -1276,6 +1277,7 @@
CF38B70D8C6DD42C00A56A27 /* LogViewerScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A84D413BF49F0E980F010A6B /* LogViewerScreenCoordinator.swift */; };
CF4044A8EED5C41BC0ED6ABE /* SoftLogoutScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = D316BB02636AF2174F2580E6 /* SoftLogoutScreenViewModelProtocol.swift */; };
CF638B8C6FDCE920AE061FAE /* StateStoreViewModelV2.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79A106D76741914F82664959 /* StateStoreViewModelV2.swift */; };
CF7D38F6478CDA5E48CA185D /* PresenceService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F62DA377B741D7B99AA7742 /* PresenceService.swift */; };
CFEC53440C572CEEABC4A6A0 /* ElementXAttributeScope.swift in Sources */ = {isa = PBXBuildFile; fileRef = C024C151639C4E1B91FCC68B /* ElementXAttributeScope.swift */; };
D029EBF4C7E519F03DCBFEEF /* SpacesScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 70BA17E542757492510664F4 /* SpacesScreenViewModelProtocol.swift */; };
D02AA6208C7ACB9BE6332394 /* UNNotificationContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE148A4FFEE853C5A281500C /* UNNotificationContent.swift */; };
@@ -2298,6 +2300,7 @@
70C86696AC9521F8ED88FBEB /* MediaUploadPreviewScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaUploadPreviewScreen.swift; sourceTree = "<group>"; };
70F8DAEF1A8131BDFD4CDE83 /* eu */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = eu; path = eu.lproj/InfoPlist.strings; sourceTree = "<group>"; };
713B48DBF65DE4B0DD445D66 /* ReportContentScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportContentScreenViewModelProtocol.swift; sourceTree = "<group>"; };
7168FDF4B2B394142FEE2A76 /* PresenceServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresenceServiceTests.swift; sourceTree = "<group>"; };
718D8767035D37E2DB5CC550 /* QRCodeLoginScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRCodeLoginScreenViewModelProtocol.swift; sourceTree = "<group>"; };
7199693797B66245EF97BCF5 /* id */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = id; path = id.lproj/InfoPlist.strings; sourceTree = "<group>"; };
71A7D4DDEEE5D2CA0C8D63CD /* SoftLogoutScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoftLogoutScreen.swift; sourceTree = "<group>"; };
@@ -2567,6 +2570,7 @@
9F1DF3FFFE5ED2B8133F43A7 /* MessageComposer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageComposer.swift; sourceTree = "<group>"; };
9F3E4640982CE98C6ADD7896 /* SearchServiceProxyProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchServiceProxyProtocol.swift; sourceTree = "<group>"; };
9F40FB0A43DAECEC27C73722 /* bg */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = bg; path = bg.lproj/SAS.strings; sourceTree = "<group>"; };
9F62DA377B741D7B99AA7742 /* PresenceService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresenceService.swift; sourceTree = "<group>"; };
9FD40B92FCF20165658296AD /* TimelineMediaPreviewModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineMediaPreviewModifier.swift; sourceTree = "<group>"; };
9FD7E851E2BA8C5A8D284B2A /* BannedRoomProxyMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BannedRoomProxyMock.swift; sourceTree = "<group>"; };
9FE8A35B4AD5256F4B562274 /* SettingsScreenViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsScreenViewModelTests.swift; sourceTree = "<group>"; };
@@ -3376,6 +3380,7 @@
6DE13A7AE6587B079F4049D7 /* Notification */,
114DC16B28140F885FD833E2 /* NotificationSettings */,
599DFFE0805B08454E40D64A /* Polls */,
89CDB9E3473D672F1E65BFAC /* Presence */,
40E6246F03D1FE377BC5D963 /* Room */,
4FFDC8D1A752384B4C6EB0EB /* RoomDirectorySearch */,
BF73FE1790E737263F0F504A /* Search */,
@@ -5081,6 +5086,7 @@
6FA38E813BE14149F173F461 /* PinnedEventsBannerStateTests.swift */,
347D708104CCEF771428C9A3 /* PollFormScreenViewModelTests.swift */,
FBD507F529D440215892618A /* PollTests.swift */,
7168FDF4B2B394142FEE2A76 /* PresenceServiceTests.swift */,
25E7E9B7FEAB6169D960C206 /* QRCodeLoginScreenViewModelTests.swift */,
181CF280BC8E3F335AFCB4B8 /* RemotePreferenceTests.swift */,
30EA681527A8FE65E4C8E9A9 /* ReportContentScreenViewModelTests.swift */,
@@ -5544,6 +5550,14 @@
path = MediaPickerScreen;
sourceTree = "<group>";
};
89CDB9E3473D672F1E65BFAC /* Presence */ = {
isa = PBXGroup;
children = (
9F62DA377B741D7B99AA7742 /* PresenceService.swift */,
);
path = Presence;
sourceTree = "<group>";
};
8A9C09B6A392465E03B8D1B1 /* IntegrationTests */ = {
isa = PBXGroup;
children = (
@@ -8080,6 +8094,7 @@
2D2D8A53B35BE8D8A01449C6 /* PinnedEventsBannerStateTests.swift in Sources */,
FF7E8ECC8E7E1D1851517536 /* PollFormScreenViewModelTests.swift in Sources */,
83A01FE4DACABDE97F04D338 /* PollTests.swift in Sources */,
8B6ED1393F94AEEFAE74C8BB /* PresenceServiceTests.swift in Sources */,
E3EBC3BF7CE3960B41757BAA /* Publisher.swift in Sources */,
BDC4EB54CC3036730475CB8B /* QRCodeLoginScreenViewModelTests.swift in Sources */,
522269133E6F65F68482F4F4 /* RemotePreferenceTests.swift in Sources */,
@@ -8824,6 +8839,7 @@
128FFD8A3D85845F9A927F47 /* PollRoomTimelineView.swift in Sources */,
1307268DC41730E5BCF7D9A0 /* PollView.swift in Sources */,
DF504B10A4918F971A57BEF2 /* PostHogAnalyticsClient.swift in Sources */,
CF7D38F6478CDA5E48CA185D /* PresenceService.swift in Sources */,
BDCF42966007770A33D90E15 /* PreviewScrollView.swift in Sources */,
6793E75E3EBE48EBB8F857AF /* ProcessInfo.swift in Sources */,
69DE29C3E3180BB17D840690 /* ProgressCursorModifier.swift in Sources */,
@@ -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()
}
}
}
+6
View File
@@ -79,6 +79,12 @@ final nonisolated class NSEUserSession: NSEUserSessionProtocol {
.homeserverUrl(url: homeserverURL)
baseClient = try await clientBuilder.build()
do {
try await baseClient.setPresence(presence: .offline, immediate: false)
} catch {
MXLog.error("Failed configuring offline presence before notification processing with error: \(error)")
}
delegateHandle = try baseClient.setDelegate(delegate: ClientDelegateWrapper())
try await baseClient.restoreSessionWith(session: credentials.restorationToken.session,
@@ -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)")
}
}