Files
element-x-ios/ElementX/Sources/Services/Client/ClientProxyProtocol.swift
T
Jared L cff46137b9 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>
2026-07-22 15:23:36 +01:00

300 lines
11 KiB
Swift

//
// Copyright 2025 Element Creations Ltd.
// Copyright 2022-2025 New Vector 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 MatrixRustSDK
enum ClientProxyAction {
case receivedSyncUpdate
case receivedAuthError(isSoftLogout: Bool)
case receivedDecryptionError(UnableToDecryptInfo)
var isSyncUpdate: Bool {
if case .receivedSyncUpdate = self {
return true
} else {
return false
}
}
}
enum ClientProxyLoadingState {
case loading
case notLoading
}
enum ClientProxyPresence: Equatable, Sendable {
case online
case unavailable
case offline
}
enum ClientProxyError: Error {
case sdkError(Error)
case forbiddenAccess
case invalidMedia
case invalidServerName
case failedUploadingMedia(ErrorKind)
case roomPreviewIsPrivate
case failedRetrievingUserIdentity
case failedResolvingRoomAlias
case roomNotInLocalStore
case invalidInvite
}
enum SlidingSyncConstants {
static let maximumVisibleRangeSize = 30
}
enum CreateRoomAccessType: Equatable {
case `public`
case spaceMembers(spaceID: String)
case askToJoinWithSpaceMembers(spaceID: String)
case askToJoin
case `private`
var isVisibilityPrivate: Bool {
switch self {
case .private, .spaceMembers, .askToJoinWithSpaceMembers:
true
case .public, .askToJoin:
false
}
}
}
/// This struct represents the configuration that we are using to register the application through Pusher to Sygnal
/// using the Matrix Rust SDK, more info here:
/// https://github.com/matrix-org/sygnal
struct PusherConfiguration {
let identifiers: PusherIdentifiers
let kind: PusherKind
let appDisplayName: String
let deviceDisplayName: String
let profileTag: String?
let lang: String
}
enum SessionVerificationState {
case unknown
case verified
case unverified
}
/// The `Decodable` conformance is just for the purpose of migration
enum TimelineMediaVisibility: Decodable {
case always
case privateOnly
case never
}
/// Represents a server-echoed update about the current user's own beacon info state in a room.
struct LiveLocationOwnInfoUpdate: Equatable {
/// The room where the beacon info event was sent.
let roomID: String
/// The event ID of the beacon info state event.
let eventID: String
/// Whether the beacon is currently active (live) or has been stopped.
let isLive: Bool
}
// sourcery: AutoMockable
protocol ClientProxyProtocol: AnyObject {
var actionsPublisher: AnyPublisher<ClientProxyAction, Never> { get }
var loadingStatePublisher: CurrentValuePublisher<ClientProxyLoadingState, Never> { get }
var verificationStatePublisher: CurrentValuePublisher<SessionVerificationState, Never> { get }
var homeserverReachabilityPublisher: CurrentValuePublisher<HomeserverReachability, Never> { get }
var userID: String { get }
var deviceID: String? { get }
var homeserver: String { get }
var canDeactivateAccount: Bool { get }
var userIDServerName: String? { get }
var userProfilePublisher: CurrentValuePublisher<UserProfile, Never> { get }
/// We delay fetching this until after the first sync. Nil until then
var ignoredUsersPublisher: CurrentValuePublisher<[String]?, Never> { get }
var timelineMediaVisibilityPublisher: CurrentValuePublisher<TimelineMediaVisibility, Never> { get }
var hideInviteAvatarsPublisher: CurrentValuePublisher<Bool, Never> { get }
var pusherNotificationClientIdentifier: String? { get }
var mediaLoader: MediaLoaderProtocol { get }
var contentScanner: ContentScannerProxyProtocol? { get }
var roomSummaryProvider: RoomSummaryProviderProtocol { get }
/// Used for listing rooms that shouldn't be affected by the main `roomSummaryProvider` filtering
/// But can still be filtered by queries, since this may be shared across multiple views, remember to reset
/// The filtering state when you are done with it
var alternateRoomSummaryProvider: RoomSummaryProviderProtocol { get }
/// Used for listing rooms, can't be filtered nor its state observed
var staticRoomSummaryProvider: StaticRoomSummaryProviderProtocol { get }
var roomsToAwait: Set<String> { get set }
var notificationSettings: NotificationSettingsProxyProtocol { get }
var secureBackupController: SecureBackupControllerProtocol { get }
var sessionVerificationController: SessionVerificationControllerProxyProtocol? { get }
var spaceService: SpaceServiceProxyProtocol { get }
var searchService: SearchServiceProxyProtocol { get }
var capabilities: HomeserverCapabilitiesProxyProtocol { get }
var isReportRoomSupported: Bool { get async }
var isLiveKitRTCSupported: Bool { get async }
var isLoginWithQRCodeSupported: Bool { get async }
var maxMediaUploadSize: Result<UInt, ClientProxyError> { get async }
func isOnlyDeviceLeft() async -> Result<Bool, ClientProxyError>
func hasDevicesToVerifyAgainst() async -> Result<Bool, ClientProxyError>
func resumeServices() async
func pauseServices() async
func expireSyncSessions() async
func accountURL(action: AccountManagementAction) async -> URL?
func directRoomForUserID(_ userID: String) -> Result<String?, ClientProxyError>
func createDirectRoom(with userID: String, expectedRoomName: String?) async -> Result<String, ClientProxyError>
func createRoom(name: String?,
topic: String?,
accessType: CreateRoomAccessType,
isSpace: Bool,
userIDs: [String],
avatarURL: URL?,
aliasLocalPart: String?) async -> Result<String, ClientProxyError>
func joinRoom(_ roomID: String, via: [String]) async -> Result<Void, ClientProxyError>
func joinRoomAlias(_ roomAlias: String) async -> Result<Void, ClientProxyError>
func knockRoom(_ roomID: String, via: [String], message: String?) async -> Result<Void, ClientProxyError>
func knockRoomAlias(_ roomAlias: String, message: String?) async -> Result<Void, ClientProxyError>
func canJoinRoom(with rules: [AllowRule]) -> Bool
func uploadMedia(_ media: MediaInfo) async -> Result<String, ClientProxyError>
func roomForIdentifier(_ identifier: String) async -> RoomProxyType?
func roomPreviewForIdentifier(_ identifier: String, via: [String]) async -> Result<RoomPreviewProxyProtocol, ClientProxyError>
func roomSummaryForIdentifier(_ identifier: String) -> RoomSummary?
func roomSummaryForAlias(_ alias: String) -> RoomSummary?
/// Will only work for rooms that are in our room list/local store
func reportRoomForIdentifier(_ identifier: String, reason: String) async -> Result<Void, ClientProxyError>
/// Loads the user's own profile when the server doesn't support MSC4262 and both returns the profile
/// as well as publishing it via ``userProfilePublisher``.
///
/// When the server does support the MSC, then the client automatically publishes profile and keeps it up to date.
@discardableResult func loadUserProfileIfNeeded() async -> Result<Void, ClientProxyError>
func setUserDisplayName(_ name: String) async -> Result<Void, ClientProxyError>
func setUserAvatar(media: MediaInfo) async -> Result<Void, ClientProxyError>
func removeUserAvatar() async -> Result<Void, ClientProxyError>
func setUserStatus(_ status: UserStatus.Raw) async -> Result<Void, ClientProxyError>
func removeUserStatus() async -> Result<Void, ClientProxyError>
func linkNewDeviceService() -> LinkNewDeviceServiceProtocol
func deactivateAccount(password: String?, eraseData: Bool) async -> Result<Void, ClientProxyError>
func logout() async
func setPusher(with configuration: PusherConfiguration) async throws
func searchUsers(searchTerm: String, limit: UInt) async -> Result<SearchUsersResults, ClientProxyError>
func profile(for userID: String) async -> Result<UserProfile, ClientProxyError>
func roomDirectorySearchProxy() -> RoomDirectorySearchProxyProtocol
func resolveRoomAlias(_ alias: String) async -> Result<ResolvedRoomAlias, ClientProxyError>
func isAliasAvailable(_ alias: String) async -> Result<Bool, ClientProxyError>
@discardableResult func clearCaches() async -> Result<Void, ClientProxyError>
@discardableResult func optimizeStores() async -> Result<Void, ClientProxyError>
@discardableResult func markAllRoomsAsRead() async -> Result<Void, ClientProxyError>
func storeSizes() async -> Result<StoreSizes, ClientProxyError>
func fetchMediaPreviewConfiguration() async -> Result<MediaPreviewConfig?, ClientProxyError>
// MARK: - Ignored users
func ignoreUser(_ userID: String) async -> Result<Void, ClientProxyError>
func unignoreUser(_ userID: String) async -> Result<Void, ClientProxyError>
// MARK: - Recently visited rooms
func trackRecentlyVisitedRoom(_ roomID: String) async -> Result<Void, ClientProxyError>
func recentlyVisitedRooms(filter: @Sendable (JoinedRoomProxyProtocol) -> Bool) async -> [JoinedRoomProxyProtocol]
func recentConversationCounterparts() async -> [UserProfile]
// MARK: - Crypto
func ed25519Base64() async -> String?
func curve25519Base64() async -> String?
func pinUserIdentity(_ userID: String) async -> Result<Void, ClientProxyError>
func withdrawUserIdentityVerification(_ userID: String) async -> Result<Void, ClientProxyError>
func resetIdentity() async -> Result<IdentityResetHandle?, ClientProxyError>
func userIdentity(for userID: String, fallBackToServer: Bool) async -> Result<UserIdentityProxyProtocol?, ClientProxyError>
// MARK: - Live Location
/// Publishes updates about the current user's own live location beacon info state changes (start/stop) as echoed by the server.
var liveLocationOwnInfoUpdatesPublisher: AnyPublisher<LiveLocationOwnInfoUpdate, Never> { get }
// MARK: - Moderation & Safety
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>
}