mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-04 01:20:12 +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 */; };
|
8B1D5CE017EEC734CF5FE130 /* Encodable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 260004737C573A56FA01E86E /* Encodable.swift */; };
|
||||||
8B408C574E35E1C9B43A50CE /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 048A21188AB19349D026BECD /* PrivacyInfo.xcprivacy */; };
|
8B408C574E35E1C9B43A50CE /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 048A21188AB19349D026BECD /* PrivacyInfo.xcprivacy */; };
|
||||||
8B41D0357B91CD3B6F6A3BCA /* EmoteRoomTimelineItemContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE378083653EF0C9B5E9D580 /* EmoteRoomTimelineItemContent.swift */; };
|
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 */; };
|
8B76191B9DDD1AC90A6E3A35 /* MediaFileHandleProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEC1D382565A4E9CAC2F14EA /* MediaFileHandleProxy.swift */; };
|
||||||
8B9C2890B6DD160A0662861E /* AudioPlaybackSpeed.swift in Sources */ = {isa = PBXBuildFile; fileRef = F78B4E56DBFFD4A7A39D10F5 /* AudioPlaybackSpeed.swift */; };
|
8B9C2890B6DD160A0662861E /* AudioPlaybackSpeed.swift in Sources */ = {isa = PBXBuildFile; fileRef = F78B4E56DBFFD4A7A39D10F5 /* AudioPlaybackSpeed.swift */; };
|
||||||
8BC8EF6705A78946C1F22891 /* SoftLogoutScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71A7D4DDEEE5D2CA0C8D63CD /* SoftLogoutScreen.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 */; };
|
CF38B70D8C6DD42C00A56A27 /* LogViewerScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A84D413BF49F0E980F010A6B /* LogViewerScreenCoordinator.swift */; };
|
||||||
CF4044A8EED5C41BC0ED6ABE /* SoftLogoutScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = D316BB02636AF2174F2580E6 /* SoftLogoutScreenViewModelProtocol.swift */; };
|
CF4044A8EED5C41BC0ED6ABE /* SoftLogoutScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = D316BB02636AF2174F2580E6 /* SoftLogoutScreenViewModelProtocol.swift */; };
|
||||||
CF638B8C6FDCE920AE061FAE /* StateStoreViewModelV2.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79A106D76741914F82664959 /* StateStoreViewModelV2.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 */; };
|
CFEC53440C572CEEABC4A6A0 /* ElementXAttributeScope.swift in Sources */ = {isa = PBXBuildFile; fileRef = C024C151639C4E1B91FCC68B /* ElementXAttributeScope.swift */; };
|
||||||
D029EBF4C7E519F03DCBFEEF /* SpacesScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 70BA17E542757492510664F4 /* SpacesScreenViewModelProtocol.swift */; };
|
D029EBF4C7E519F03DCBFEEF /* SpacesScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 70BA17E542757492510664F4 /* SpacesScreenViewModelProtocol.swift */; };
|
||||||
D02AA6208C7ACB9BE6332394 /* UNNotificationContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE148A4FFEE853C5A281500C /* UNNotificationContent.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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
9FE8A35B4AD5256F4B562274 /* SettingsScreenViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsScreenViewModelTests.swift; sourceTree = "<group>"; };
|
||||||
@@ -3376,6 +3380,7 @@
|
|||||||
6DE13A7AE6587B079F4049D7 /* Notification */,
|
6DE13A7AE6587B079F4049D7 /* Notification */,
|
||||||
114DC16B28140F885FD833E2 /* NotificationSettings */,
|
114DC16B28140F885FD833E2 /* NotificationSettings */,
|
||||||
599DFFE0805B08454E40D64A /* Polls */,
|
599DFFE0805B08454E40D64A /* Polls */,
|
||||||
|
89CDB9E3473D672F1E65BFAC /* Presence */,
|
||||||
40E6246F03D1FE377BC5D963 /* Room */,
|
40E6246F03D1FE377BC5D963 /* Room */,
|
||||||
4FFDC8D1A752384B4C6EB0EB /* RoomDirectorySearch */,
|
4FFDC8D1A752384B4C6EB0EB /* RoomDirectorySearch */,
|
||||||
BF73FE1790E737263F0F504A /* Search */,
|
BF73FE1790E737263F0F504A /* Search */,
|
||||||
@@ -5081,6 +5086,7 @@
|
|||||||
6FA38E813BE14149F173F461 /* PinnedEventsBannerStateTests.swift */,
|
6FA38E813BE14149F173F461 /* PinnedEventsBannerStateTests.swift */,
|
||||||
347D708104CCEF771428C9A3 /* PollFormScreenViewModelTests.swift */,
|
347D708104CCEF771428C9A3 /* PollFormScreenViewModelTests.swift */,
|
||||||
FBD507F529D440215892618A /* PollTests.swift */,
|
FBD507F529D440215892618A /* PollTests.swift */,
|
||||||
|
7168FDF4B2B394142FEE2A76 /* PresenceServiceTests.swift */,
|
||||||
25E7E9B7FEAB6169D960C206 /* QRCodeLoginScreenViewModelTests.swift */,
|
25E7E9B7FEAB6169D960C206 /* QRCodeLoginScreenViewModelTests.swift */,
|
||||||
181CF280BC8E3F335AFCB4B8 /* RemotePreferenceTests.swift */,
|
181CF280BC8E3F335AFCB4B8 /* RemotePreferenceTests.swift */,
|
||||||
30EA681527A8FE65E4C8E9A9 /* ReportContentScreenViewModelTests.swift */,
|
30EA681527A8FE65E4C8E9A9 /* ReportContentScreenViewModelTests.swift */,
|
||||||
@@ -5544,6 +5550,14 @@
|
|||||||
path = MediaPickerScreen;
|
path = MediaPickerScreen;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
89CDB9E3473D672F1E65BFAC /* Presence */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
9F62DA377B741D7B99AA7742 /* PresenceService.swift */,
|
||||||
|
);
|
||||||
|
path = Presence;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
8A9C09B6A392465E03B8D1B1 /* IntegrationTests */ = {
|
8A9C09B6A392465E03B8D1B1 /* IntegrationTests */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
@@ -8080,6 +8094,7 @@
|
|||||||
2D2D8A53B35BE8D8A01449C6 /* PinnedEventsBannerStateTests.swift in Sources */,
|
2D2D8A53B35BE8D8A01449C6 /* PinnedEventsBannerStateTests.swift in Sources */,
|
||||||
FF7E8ECC8E7E1D1851517536 /* PollFormScreenViewModelTests.swift in Sources */,
|
FF7E8ECC8E7E1D1851517536 /* PollFormScreenViewModelTests.swift in Sources */,
|
||||||
83A01FE4DACABDE97F04D338 /* PollTests.swift in Sources */,
|
83A01FE4DACABDE97F04D338 /* PollTests.swift in Sources */,
|
||||||
|
8B6ED1393F94AEEFAE74C8BB /* PresenceServiceTests.swift in Sources */,
|
||||||
E3EBC3BF7CE3960B41757BAA /* Publisher.swift in Sources */,
|
E3EBC3BF7CE3960B41757BAA /* Publisher.swift in Sources */,
|
||||||
BDC4EB54CC3036730475CB8B /* QRCodeLoginScreenViewModelTests.swift in Sources */,
|
BDC4EB54CC3036730475CB8B /* QRCodeLoginScreenViewModelTests.swift in Sources */,
|
||||||
522269133E6F65F68482F4F4 /* RemotePreferenceTests.swift in Sources */,
|
522269133E6F65F68482F4F4 /* RemotePreferenceTests.swift in Sources */,
|
||||||
@@ -8824,6 +8839,7 @@
|
|||||||
128FFD8A3D85845F9A927F47 /* PollRoomTimelineView.swift in Sources */,
|
128FFD8A3D85845F9A927F47 /* PollRoomTimelineView.swift in Sources */,
|
||||||
1307268DC41730E5BCF7D9A0 /* PollView.swift in Sources */,
|
1307268DC41730E5BCF7D9A0 /* PollView.swift in Sources */,
|
||||||
DF504B10A4918F971A57BEF2 /* PostHogAnalyticsClient.swift in Sources */,
|
DF504B10A4918F971A57BEF2 /* PostHogAnalyticsClient.swift in Sources */,
|
||||||
|
CF7D38F6478CDA5E48CA185D /* PresenceService.swift in Sources */,
|
||||||
BDCF42966007770A33D90E15 /* PreviewScrollView.swift in Sources */,
|
BDCF42966007770A33D90E15 /* PreviewScrollView.swift in Sources */,
|
||||||
6793E75E3EBE48EBB8F857AF /* ProcessInfo.swift in Sources */,
|
6793E75E3EBE48EBB8F857AF /* ProcessInfo.swift in Sources */,
|
||||||
69DE29C3E3180BB17D840690 /* ProgressCursorModifier.swift in Sources */,
|
69DE29C3E3180BB17D840690 /* ProgressCursorModifier.swift in Sources */,
|
||||||
|
|||||||
@@ -1285,6 +1285,10 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg
|
|||||||
return
|
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()
|
await resumeClientServices()
|
||||||
|
|
||||||
// Be a good citizen, run for a max of 10 SS responses or 10 seconds
|
// 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 navigationTabCoordinator: NavigationTabCoordinator<HomeTab>
|
||||||
private let appLockService: AppLockServiceProtocol
|
private let appLockService: AppLockServiceProtocol
|
||||||
private let flowParameters: CommonFlowParameters
|
private let flowParameters: CommonFlowParameters
|
||||||
|
// periphery:ignore - retaining purpose
|
||||||
|
private let presenceService: PresenceService
|
||||||
|
|
||||||
private var userSession: UserSessionProtocol {
|
private var userSession: UserSessionProtocol {
|
||||||
flowParameters.userSession
|
flowParameters.userSession
|
||||||
@@ -78,6 +80,8 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
|
|||||||
self.navigationRootCoordinator = navigationRootCoordinator
|
self.navigationRootCoordinator = navigationRootCoordinator
|
||||||
self.appLockService = appLockService
|
self.appLockService = appLockService
|
||||||
self.flowParameters = flowParameters
|
self.flowParameters = flowParameters
|
||||||
|
presenceService = PresenceService(clientProxy: flowParameters.userSession.clientProxy,
|
||||||
|
appSettings: flowParameters.appSettings)
|
||||||
|
|
||||||
navigationTabCoordinator = NavigationTabCoordinator()
|
navigationTabCoordinator = NavigationTabCoordinator()
|
||||||
navigationRootCoordinator.setRootCoordinator(navigationTabCoordinator)
|
navigationRootCoordinator.setRootCoordinator(navigationTabCoordinator)
|
||||||
|
|||||||
@@ -167,5 +167,7 @@ extension ClientProxyMock {
|
|||||||
underlyingMaxMediaUploadSize = .success(configuration.maxMediaUploadSize)
|
underlyingMaxMediaUploadSize = .success(configuration.maxMediaUploadSize)
|
||||||
|
|
||||||
storeSizesReturnValue = .success(.init(cryptoStore: 1, stateStore: 9, eventCacheStore: 8, mediaStore: 6))
|
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
|
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 {
|
nonisolated class CompletionSuggestionServiceMock: CompletionSuggestionServiceProtocol, @unchecked Sendable {
|
||||||
var suggestionsPublisher: AnyPublisher<[SuggestionItem], Never> {
|
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
|
// MARK: - Private
|
||||||
|
|
||||||
private func setupSubscriptions(canSubscribeToUserProfile: Bool) async {
|
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 {
|
private extension CreateRoomAccessType {
|
||||||
var isEncrypted: Bool {
|
var isEncrypted: Bool {
|
||||||
switch self {
|
switch self {
|
||||||
|
|||||||
@@ -29,6 +29,12 @@ enum ClientProxyLoadingState {
|
|||||||
case notLoading
|
case notLoading
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum ClientProxyPresence: Equatable, Sendable {
|
||||||
|
case online
|
||||||
|
case unavailable
|
||||||
|
case offline
|
||||||
|
}
|
||||||
|
|
||||||
enum ClientProxyError: Error {
|
enum ClientProxyError: Error {
|
||||||
case sdkError(Error)
|
case sdkError(Error)
|
||||||
case forbiddenAccess
|
case forbiddenAccess
|
||||||
@@ -284,4 +290,10 @@ protocol ClientProxyProtocol: AnyObject {
|
|||||||
|
|
||||||
func setTimelineMediaVisibility(_ value: TimelineMediaVisibility) async -> Result<Void, ClientProxyError>
|
func setTimelineMediaVisibility(_ value: TimelineMediaVisibility) async -> Result<Void, ClientProxyError>
|
||||||
func setHideInviteAvatars(_ value: Bool) 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)
|
.homeserverUrl(url: homeserverURL)
|
||||||
|
|
||||||
baseClient = try await clientBuilder.build()
|
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())
|
delegateHandle = try baseClient.setDelegate(delegate: ClientDelegateWrapper())
|
||||||
|
|
||||||
try await baseClient.restoreSessionWith(session: credentials.restorationToken.session,
|
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