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:
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user