diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index a5d859da0..708885212 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -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 = ""; }; 70F8DAEF1A8131BDFD4CDE83 /* eu */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = eu; path = eu.lproj/InfoPlist.strings; sourceTree = ""; }; 713B48DBF65DE4B0DD445D66 /* ReportContentScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportContentScreenViewModelProtocol.swift; sourceTree = ""; }; + 7168FDF4B2B394142FEE2A76 /* PresenceServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresenceServiceTests.swift; sourceTree = ""; }; 718D8767035D37E2DB5CC550 /* QRCodeLoginScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRCodeLoginScreenViewModelProtocol.swift; sourceTree = ""; }; 7199693797B66245EF97BCF5 /* id */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = id; path = id.lproj/InfoPlist.strings; sourceTree = ""; }; 71A7D4DDEEE5D2CA0C8D63CD /* SoftLogoutScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoftLogoutScreen.swift; sourceTree = ""; }; @@ -2567,6 +2570,7 @@ 9F1DF3FFFE5ED2B8133F43A7 /* MessageComposer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageComposer.swift; sourceTree = ""; }; 9F3E4640982CE98C6ADD7896 /* SearchServiceProxyProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchServiceProxyProtocol.swift; sourceTree = ""; }; 9F40FB0A43DAECEC27C73722 /* bg */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = bg; path = bg.lproj/SAS.strings; sourceTree = ""; }; + 9F62DA377B741D7B99AA7742 /* PresenceService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresenceService.swift; sourceTree = ""; }; 9FD40B92FCF20165658296AD /* TimelineMediaPreviewModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineMediaPreviewModifier.swift; sourceTree = ""; }; 9FD7E851E2BA8C5A8D284B2A /* BannedRoomProxyMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BannedRoomProxyMock.swift; sourceTree = ""; }; 9FE8A35B4AD5256F4B562274 /* SettingsScreenViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsScreenViewModelTests.swift; sourceTree = ""; }; @@ -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 = ""; }; + 89CDB9E3473D672F1E65BFAC /* Presence */ = { + isa = PBXGroup; + children = ( + 9F62DA377B741D7B99AA7742 /* PresenceService.swift */, + ); + path = Presence; + sourceTree = ""; + }; 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 */, diff --git a/ElementX/Sources/Application/AppCoordinator.swift b/ElementX/Sources/Application/AppCoordinator.swift index bc63375bd..dc54e5bbd 100644 --- a/ElementX/Sources/Application/AppCoordinator.swift +++ b/ElementX/Sources/Application/AppCoordinator.swift @@ -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 diff --git a/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift index 39c7e7ac9..cb695f817 100644 --- a/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift @@ -26,6 +26,8 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol { private let navigationTabCoordinator: NavigationTabCoordinator 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) diff --git a/ElementX/Sources/Mocks/ClientProxyMock.swift b/ElementX/Sources/Mocks/ClientProxyMock.swift index 07db708f8..09a46983a 100644 --- a/ElementX/Sources/Mocks/ClientProxyMock.swift +++ b/ElementX/Sources/Mocks/ClientProxyMock.swift @@ -167,5 +167,7 @@ extension ClientProxyMock { underlyingMaxMediaUploadSize = .success(configuration.maxMediaUploadSize) storeSizesReturnValue = .success(.init(cryptoStore: 1, stateStore: 9, eventCacheStore: 8, mediaStore: 6)) + + configurePresenceSendImmediatelyReturnValue = .success(()) } } diff --git a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift index 7343d50be..147029e01 100644 --- a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift +++ b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift @@ -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! + var configurePresenceSendImmediatelyReturnValue: Result! { + get { configurePresenceSendImmediatelyReturnValueLock.withLock { configurePresenceSendImmediatelyUnderlyingReturnValue } } + set { configurePresenceSendImmediatelyReturnValueLock.withLock { configurePresenceSendImmediatelyUnderlyingReturnValue = newValue } } + } + nonisolated(unsafe) var configurePresenceSendImmediatelyClosure: ((ClientProxyPresence, Bool) async -> Result)? + + @concurrent func configurePresence(_ presence: ClientProxyPresence, sendImmediately: Bool) async -> Result { + 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> { diff --git a/ElementX/Sources/Services/Client/ClientProxy.swift b/ElementX/Sources/Services/Client/ClientProxy.swift index 162ae4277..c59af281d 100644 --- a/ElementX/Sources/Services/Client/ClientProxy.swift +++ b/ElementX/Sources/Services/Client/ClientProxy.swift @@ -1026,6 +1026,18 @@ class ClientProxy: ClientProxyProtocol { } } + // MARK: Presence + + func configurePresence(_ presence: ClientProxyPresence, sendImmediately: Bool) async -> Result { + 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 { diff --git a/ElementX/Sources/Services/Client/ClientProxyProtocol.swift b/ElementX/Sources/Services/Client/ClientProxyProtocol.swift index d8e0496bf..ccf5d649d 100644 --- a/ElementX/Sources/Services/Client/ClientProxyProtocol.swift +++ b/ElementX/Sources/Services/Client/ClientProxyProtocol.swift @@ -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 func setHideInviteAvatars(_ value: Bool) async -> Result + + // 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 } diff --git a/ElementX/Sources/Services/Presence/PresenceService.swift b/ElementX/Sources/Services/Presence/PresenceService.swift new file mode 100644 index 000000000..178367130 --- /dev/null +++ b/ElementX/Sources/Services/Presence/PresenceService.swift @@ -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? + + private var cancellables = Set() + + 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() + } + } +} diff --git a/NSE/Sources/NSEUserSession.swift b/NSE/Sources/NSEUserSession.swift index 7d5042bc6..5c2ecbaa0 100644 --- a/NSE/Sources/NSEUserSession.swift +++ b/NSE/Sources/NSEUserSession.swift @@ -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, diff --git a/UnitTests/Sources/PresenceServiceTests.swift b/UnitTests/Sources/PresenceServiceTests.swift new file mode 100644 index 000000000..5bd3f2f6e --- /dev/null +++ b/UnitTests/Sources/PresenceServiceTests.swift @@ -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() + let gate = AsyncStream.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 = 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)") + } +}