mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-10-31 08:15:24 +00:00
Add HealthChat API and extended profile services (#9)
Expose OpenID tokens and homeserver credentials from the client proxy, and add fork-only services for the HealthChat member directory backend (api.healthchat.ch) plus a service that reads MSC4133 extended profile fields directly from the homeserver. Groundwork for the members tab and the profile screens. Claude-Session: https://claude.ai/code/session_013qdTbAWsZj2quQzEkagXaS
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
//
|
||||
// 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 Foundation
|
||||
|
||||
/// Fork-only constants for the HealthChat backend. Injected through service
|
||||
/// initialisers so tests can point them at a mock server.
|
||||
enum HealthChatSettings {
|
||||
static let apiBaseURL: URL = "https://api.healthchat.ch"
|
||||
}
|
||||
@@ -3879,6 +3879,62 @@ nonisolated class ClientProxyMock: ClientProxyProtocol, @unchecked Sendable {
|
||||
return profileForReturnValue
|
||||
}
|
||||
}
|
||||
//MARK: - requestOpenIDToken
|
||||
|
||||
private let requestOpenIDTokenCallsCountLock = NSLock()
|
||||
private nonisolated(unsafe) var requestOpenIDTokenUnderlyingCallsCount = 0
|
||||
var requestOpenIDTokenCallsCount: Int {
|
||||
get { requestOpenIDTokenCallsCountLock.withLock { requestOpenIDTokenUnderlyingCallsCount } }
|
||||
set { requestOpenIDTokenCallsCountLock.withLock { requestOpenIDTokenUnderlyingCallsCount = newValue } }
|
||||
}
|
||||
var requestOpenIDTokenCalled: Bool {
|
||||
return requestOpenIDTokenCallsCount > 0
|
||||
}
|
||||
|
||||
private let requestOpenIDTokenReturnValueLock = NSLock()
|
||||
private nonisolated(unsafe) var requestOpenIDTokenUnderlyingReturnValue: Result<OpenIDToken, ClientProxyError>!
|
||||
var requestOpenIDTokenReturnValue: Result<OpenIDToken, ClientProxyError>! {
|
||||
get { requestOpenIDTokenReturnValueLock.withLock { requestOpenIDTokenUnderlyingReturnValue } }
|
||||
set { requestOpenIDTokenReturnValueLock.withLock { requestOpenIDTokenUnderlyingReturnValue = newValue } }
|
||||
}
|
||||
nonisolated(unsafe) var requestOpenIDTokenClosure: (() async -> Result<OpenIDToken, ClientProxyError>)?
|
||||
|
||||
@concurrent func requestOpenIDToken() async -> Result<OpenIDToken, ClientProxyError> {
|
||||
requestOpenIDTokenCallsCountLock.withLock { requestOpenIDTokenUnderlyingCallsCount += 1 }
|
||||
if let requestOpenIDTokenClosure = requestOpenIDTokenClosure {
|
||||
return await requestOpenIDTokenClosure()
|
||||
} else {
|
||||
return requestOpenIDTokenReturnValue
|
||||
}
|
||||
}
|
||||
//MARK: - homeserverCredentials
|
||||
|
||||
private let homeserverCredentialsCallsCountLock = NSLock()
|
||||
private nonisolated(unsafe) var homeserverCredentialsUnderlyingCallsCount = 0
|
||||
var homeserverCredentialsCallsCount: Int {
|
||||
get { homeserverCredentialsCallsCountLock.withLock { homeserverCredentialsUnderlyingCallsCount } }
|
||||
set { homeserverCredentialsCallsCountLock.withLock { homeserverCredentialsUnderlyingCallsCount = newValue } }
|
||||
}
|
||||
var homeserverCredentialsCalled: Bool {
|
||||
return homeserverCredentialsCallsCount > 0
|
||||
}
|
||||
|
||||
private let homeserverCredentialsReturnValueLock = NSLock()
|
||||
private nonisolated(unsafe) var homeserverCredentialsUnderlyingReturnValue: Result<HomeserverCredentials, ClientProxyError>!
|
||||
var homeserverCredentialsReturnValue: Result<HomeserverCredentials, ClientProxyError>! {
|
||||
get { homeserverCredentialsReturnValueLock.withLock { homeserverCredentialsUnderlyingReturnValue } }
|
||||
set { homeserverCredentialsReturnValueLock.withLock { homeserverCredentialsUnderlyingReturnValue = newValue } }
|
||||
}
|
||||
nonisolated(unsafe) var homeserverCredentialsClosure: (() -> Result<HomeserverCredentials, ClientProxyError>)?
|
||||
|
||||
func homeserverCredentials() -> Result<HomeserverCredentials, ClientProxyError> {
|
||||
homeserverCredentialsCallsCountLock.withLock { homeserverCredentialsUnderlyingCallsCount += 1 }
|
||||
if let homeserverCredentialsClosure = homeserverCredentialsClosure {
|
||||
return homeserverCredentialsClosure()
|
||||
} else {
|
||||
return homeserverCredentialsReturnValue
|
||||
}
|
||||
}
|
||||
//MARK: - roomDirectorySearchProxy
|
||||
|
||||
private let roomDirectorySearchProxyCallsCountLock = NSLock()
|
||||
@@ -5483,6 +5539,284 @@ nonisolated class ElementCallWidgetDriverMock: ElementCallWidgetDriverProtocol,
|
||||
}
|
||||
}
|
||||
}
|
||||
nonisolated class ExtendedProfileServiceMock: ExtendedProfileServiceProtocol, @unchecked Sendable {
|
||||
|
||||
//MARK: - profile
|
||||
|
||||
private let profileForCallsCountLock = NSLock()
|
||||
private nonisolated(unsafe) var profileForUnderlyingCallsCount = 0
|
||||
var profileForCallsCount: Int {
|
||||
get { profileForCallsCountLock.withLock { profileForUnderlyingCallsCount } }
|
||||
set { profileForCallsCountLock.withLock { profileForUnderlyingCallsCount = newValue } }
|
||||
}
|
||||
var profileForCalled: Bool {
|
||||
return profileForCallsCount > 0
|
||||
}
|
||||
private let profileForReceivedUserIDLock = NSLock()
|
||||
private nonisolated(unsafe) var profileForUnderlyingReceivedUserID: String?
|
||||
var profileForReceivedUserID: String? {
|
||||
get { profileForReceivedUserIDLock.withLock { profileForUnderlyingReceivedUserID } }
|
||||
set { profileForReceivedUserIDLock.withLock { profileForUnderlyingReceivedUserID = newValue } }
|
||||
}
|
||||
private let profileForReceivedInvocationsLock = NSLock()
|
||||
private nonisolated(unsafe) var profileForUnderlyingReceivedInvocations: [String] = []
|
||||
var profileForReceivedInvocations: [String] {
|
||||
get { profileForReceivedInvocationsLock.withLock { profileForUnderlyingReceivedInvocations } }
|
||||
set { profileForReceivedInvocationsLock.withLock { profileForUnderlyingReceivedInvocations = newValue } }
|
||||
}
|
||||
|
||||
private let profileForReturnValueLock = NSLock()
|
||||
private nonisolated(unsafe) var profileForUnderlyingReturnValue: Result<ExtendedProfile, ExtendedProfileServiceError>!
|
||||
var profileForReturnValue: Result<ExtendedProfile, ExtendedProfileServiceError>! {
|
||||
get { profileForReturnValueLock.withLock { profileForUnderlyingReturnValue } }
|
||||
set { profileForReturnValueLock.withLock { profileForUnderlyingReturnValue = newValue } }
|
||||
}
|
||||
nonisolated(unsafe) var profileForClosure: ((String) async -> Result<ExtendedProfile, ExtendedProfileServiceError>)?
|
||||
|
||||
@concurrent func profile(for userID: String) async -> Result<ExtendedProfile, ExtendedProfileServiceError> {
|
||||
profileForCallsCountLock.withLock { profileForUnderlyingCallsCount += 1 }
|
||||
profileForReceivedUserID = userID
|
||||
profileForReceivedInvocationsLock.withLock { profileForUnderlyingReceivedInvocations.append(userID) }
|
||||
if let profileForClosure = profileForClosure {
|
||||
return await profileForClosure(userID)
|
||||
} else {
|
||||
return profileForReturnValue
|
||||
}
|
||||
}
|
||||
}
|
||||
nonisolated class HealthChatAPIServiceMock: HealthChatAPIServiceProtocol, @unchecked Sendable {
|
||||
|
||||
//MARK: - members
|
||||
|
||||
private let membersSearchQueryCallsCountLock = NSLock()
|
||||
private nonisolated(unsafe) var membersSearchQueryUnderlyingCallsCount = 0
|
||||
var membersSearchQueryCallsCount: Int {
|
||||
get { membersSearchQueryCallsCountLock.withLock { membersSearchQueryUnderlyingCallsCount } }
|
||||
set { membersSearchQueryCallsCountLock.withLock { membersSearchQueryUnderlyingCallsCount = newValue } }
|
||||
}
|
||||
var membersSearchQueryCalled: Bool {
|
||||
return membersSearchQueryCallsCount > 0
|
||||
}
|
||||
private let membersSearchQueryReceivedSearchQueryLock = NSLock()
|
||||
private nonisolated(unsafe) var membersSearchQueryUnderlyingReceivedSearchQuery: String?
|
||||
var membersSearchQueryReceivedSearchQuery: String? {
|
||||
get { membersSearchQueryReceivedSearchQueryLock.withLock { membersSearchQueryUnderlyingReceivedSearchQuery } }
|
||||
set { membersSearchQueryReceivedSearchQueryLock.withLock { membersSearchQueryUnderlyingReceivedSearchQuery = newValue } }
|
||||
}
|
||||
private let membersSearchQueryReceivedInvocationsLock = NSLock()
|
||||
private nonisolated(unsafe) var membersSearchQueryUnderlyingReceivedInvocations: [String?] = []
|
||||
var membersSearchQueryReceivedInvocations: [String?] {
|
||||
get { membersSearchQueryReceivedInvocationsLock.withLock { membersSearchQueryUnderlyingReceivedInvocations } }
|
||||
set { membersSearchQueryReceivedInvocationsLock.withLock { membersSearchQueryUnderlyingReceivedInvocations = newValue } }
|
||||
}
|
||||
|
||||
private let membersSearchQueryReturnValueLock = NSLock()
|
||||
private nonisolated(unsafe) var membersSearchQueryUnderlyingReturnValue: Result<HealthChatMembersResponse, HealthChatAPIError>!
|
||||
var membersSearchQueryReturnValue: Result<HealthChatMembersResponse, HealthChatAPIError>! {
|
||||
get { membersSearchQueryReturnValueLock.withLock { membersSearchQueryUnderlyingReturnValue } }
|
||||
set { membersSearchQueryReturnValueLock.withLock { membersSearchQueryUnderlyingReturnValue = newValue } }
|
||||
}
|
||||
nonisolated(unsafe) var membersSearchQueryClosure: ((String?) async -> Result<HealthChatMembersResponse, HealthChatAPIError>)?
|
||||
|
||||
@concurrent func members(searchQuery: String?) async -> Result<HealthChatMembersResponse, HealthChatAPIError> {
|
||||
membersSearchQueryCallsCountLock.withLock { membersSearchQueryUnderlyingCallsCount += 1 }
|
||||
membersSearchQueryReceivedSearchQuery = searchQuery
|
||||
membersSearchQueryReceivedInvocationsLock.withLock { membersSearchQueryUnderlyingReceivedInvocations.append(searchQuery) }
|
||||
if let membersSearchQueryClosure = membersSearchQueryClosure {
|
||||
return await membersSearchQueryClosure(searchQuery)
|
||||
} else {
|
||||
return membersSearchQueryReturnValue
|
||||
}
|
||||
}
|
||||
//MARK: - ownProfile
|
||||
|
||||
private let ownProfileCallsCountLock = NSLock()
|
||||
private nonisolated(unsafe) var ownProfileUnderlyingCallsCount = 0
|
||||
var ownProfileCallsCount: Int {
|
||||
get { ownProfileCallsCountLock.withLock { ownProfileUnderlyingCallsCount } }
|
||||
set { ownProfileCallsCountLock.withLock { ownProfileUnderlyingCallsCount = newValue } }
|
||||
}
|
||||
var ownProfileCalled: Bool {
|
||||
return ownProfileCallsCount > 0
|
||||
}
|
||||
|
||||
private let ownProfileReturnValueLock = NSLock()
|
||||
private nonisolated(unsafe) var ownProfileUnderlyingReturnValue: Result<HealthChatMember, HealthChatAPIError>!
|
||||
var ownProfileReturnValue: Result<HealthChatMember, HealthChatAPIError>! {
|
||||
get { ownProfileReturnValueLock.withLock { ownProfileUnderlyingReturnValue } }
|
||||
set { ownProfileReturnValueLock.withLock { ownProfileUnderlyingReturnValue = newValue } }
|
||||
}
|
||||
nonisolated(unsafe) var ownProfileClosure: (() async -> Result<HealthChatMember, HealthChatAPIError>)?
|
||||
|
||||
@concurrent func ownProfile() async -> Result<HealthChatMember, HealthChatAPIError> {
|
||||
ownProfileCallsCountLock.withLock { ownProfileUnderlyingCallsCount += 1 }
|
||||
if let ownProfileClosure = ownProfileClosure {
|
||||
return await ownProfileClosure()
|
||||
} else {
|
||||
return ownProfileReturnValue
|
||||
}
|
||||
}
|
||||
//MARK: - updateOwnProfile
|
||||
|
||||
private let updateOwnProfileCallsCountLock = NSLock()
|
||||
private nonisolated(unsafe) var updateOwnProfileUnderlyingCallsCount = 0
|
||||
var updateOwnProfileCallsCount: Int {
|
||||
get { updateOwnProfileCallsCountLock.withLock { updateOwnProfileUnderlyingCallsCount } }
|
||||
set { updateOwnProfileCallsCountLock.withLock { updateOwnProfileUnderlyingCallsCount = newValue } }
|
||||
}
|
||||
var updateOwnProfileCalled: Bool {
|
||||
return updateOwnProfileCallsCount > 0
|
||||
}
|
||||
private let updateOwnProfileReceivedUpdateLock = NSLock()
|
||||
private nonisolated(unsafe) var updateOwnProfileUnderlyingReceivedUpdate: HealthChatProfileUpdate?
|
||||
var updateOwnProfileReceivedUpdate: HealthChatProfileUpdate? {
|
||||
get { updateOwnProfileReceivedUpdateLock.withLock { updateOwnProfileUnderlyingReceivedUpdate } }
|
||||
set { updateOwnProfileReceivedUpdateLock.withLock { updateOwnProfileUnderlyingReceivedUpdate = newValue } }
|
||||
}
|
||||
private let updateOwnProfileReceivedInvocationsLock = NSLock()
|
||||
private nonisolated(unsafe) var updateOwnProfileUnderlyingReceivedInvocations: [HealthChatProfileUpdate] = []
|
||||
var updateOwnProfileReceivedInvocations: [HealthChatProfileUpdate] {
|
||||
get { updateOwnProfileReceivedInvocationsLock.withLock { updateOwnProfileUnderlyingReceivedInvocations } }
|
||||
set { updateOwnProfileReceivedInvocationsLock.withLock { updateOwnProfileUnderlyingReceivedInvocations = newValue } }
|
||||
}
|
||||
|
||||
private let updateOwnProfileReturnValueLock = NSLock()
|
||||
private nonisolated(unsafe) var updateOwnProfileUnderlyingReturnValue: Result<Void, HealthChatAPIError>!
|
||||
var updateOwnProfileReturnValue: Result<Void, HealthChatAPIError>! {
|
||||
get { updateOwnProfileReturnValueLock.withLock { updateOwnProfileUnderlyingReturnValue } }
|
||||
set { updateOwnProfileReturnValueLock.withLock { updateOwnProfileUnderlyingReturnValue = newValue } }
|
||||
}
|
||||
nonisolated(unsafe) var updateOwnProfileClosure: ((HealthChatProfileUpdate) async -> Result<Void, HealthChatAPIError>)?
|
||||
|
||||
@concurrent func updateOwnProfile(_ update: HealthChatProfileUpdate) async -> Result<Void, HealthChatAPIError> {
|
||||
updateOwnProfileCallsCountLock.withLock { updateOwnProfileUnderlyingCallsCount += 1 }
|
||||
updateOwnProfileReceivedUpdate = update
|
||||
updateOwnProfileReceivedInvocationsLock.withLock { updateOwnProfileUnderlyingReceivedInvocations.append(update) }
|
||||
if let updateOwnProfileClosure = updateOwnProfileClosure {
|
||||
return await updateOwnProfileClosure(update)
|
||||
} else {
|
||||
return updateOwnProfileReturnValue
|
||||
}
|
||||
}
|
||||
//MARK: - profileCompleteness
|
||||
|
||||
private let profileCompletenessCallsCountLock = NSLock()
|
||||
private nonisolated(unsafe) var profileCompletenessUnderlyingCallsCount = 0
|
||||
var profileCompletenessCallsCount: Int {
|
||||
get { profileCompletenessCallsCountLock.withLock { profileCompletenessUnderlyingCallsCount } }
|
||||
set { profileCompletenessCallsCountLock.withLock { profileCompletenessUnderlyingCallsCount = newValue } }
|
||||
}
|
||||
var profileCompletenessCalled: Bool {
|
||||
return profileCompletenessCallsCount > 0
|
||||
}
|
||||
|
||||
private let profileCompletenessReturnValueLock = NSLock()
|
||||
private nonisolated(unsafe) var profileCompletenessUnderlyingReturnValue: Result<HealthChatProfileCompleteness, HealthChatAPIError>!
|
||||
var profileCompletenessReturnValue: Result<HealthChatProfileCompleteness, HealthChatAPIError>! {
|
||||
get { profileCompletenessReturnValueLock.withLock { profileCompletenessUnderlyingReturnValue } }
|
||||
set { profileCompletenessReturnValueLock.withLock { profileCompletenessUnderlyingReturnValue = newValue } }
|
||||
}
|
||||
nonisolated(unsafe) var profileCompletenessClosure: (() async -> Result<HealthChatProfileCompleteness, HealthChatAPIError>)?
|
||||
|
||||
@concurrent func profileCompleteness() async -> Result<HealthChatProfileCompleteness, HealthChatAPIError> {
|
||||
profileCompletenessCallsCountLock.withLock { profileCompletenessUnderlyingCallsCount += 1 }
|
||||
if let profileCompletenessClosure = profileCompletenessClosure {
|
||||
return await profileCompletenessClosure()
|
||||
} else {
|
||||
return profileCompletenessReturnValue
|
||||
}
|
||||
}
|
||||
//MARK: - inviteMember
|
||||
|
||||
private let inviteMemberEmailPersonalNoteCallsCountLock = NSLock()
|
||||
private nonisolated(unsafe) var inviteMemberEmailPersonalNoteUnderlyingCallsCount = 0
|
||||
var inviteMemberEmailPersonalNoteCallsCount: Int {
|
||||
get { inviteMemberEmailPersonalNoteCallsCountLock.withLock { inviteMemberEmailPersonalNoteUnderlyingCallsCount } }
|
||||
set { inviteMemberEmailPersonalNoteCallsCountLock.withLock { inviteMemberEmailPersonalNoteUnderlyingCallsCount = newValue } }
|
||||
}
|
||||
var inviteMemberEmailPersonalNoteCalled: Bool {
|
||||
return inviteMemberEmailPersonalNoteCallsCount > 0
|
||||
}
|
||||
private let inviteMemberEmailPersonalNoteReceivedArgumentsLock = NSLock()
|
||||
private nonisolated(unsafe) var inviteMemberEmailPersonalNoteUnderlyingReceivedArguments: (email: String, personalNote: String)?
|
||||
var inviteMemberEmailPersonalNoteReceivedArguments: (email: String, personalNote: String)? {
|
||||
get { inviteMemberEmailPersonalNoteReceivedArgumentsLock.withLock { inviteMemberEmailPersonalNoteUnderlyingReceivedArguments } }
|
||||
set { inviteMemberEmailPersonalNoteReceivedArgumentsLock.withLock { inviteMemberEmailPersonalNoteUnderlyingReceivedArguments = newValue } }
|
||||
}
|
||||
private let inviteMemberEmailPersonalNoteReceivedInvocationsLock = NSLock()
|
||||
private nonisolated(unsafe) var inviteMemberEmailPersonalNoteUnderlyingReceivedInvocations: [(email: String, personalNote: String)] = []
|
||||
var inviteMemberEmailPersonalNoteReceivedInvocations: [(email: String, personalNote: String)] {
|
||||
get { inviteMemberEmailPersonalNoteReceivedInvocationsLock.withLock { inviteMemberEmailPersonalNoteUnderlyingReceivedInvocations } }
|
||||
set { inviteMemberEmailPersonalNoteReceivedInvocationsLock.withLock { inviteMemberEmailPersonalNoteUnderlyingReceivedInvocations = newValue } }
|
||||
}
|
||||
|
||||
private let inviteMemberEmailPersonalNoteReturnValueLock = NSLock()
|
||||
private nonisolated(unsafe) var inviteMemberEmailPersonalNoteUnderlyingReturnValue: Result<Void, HealthChatAPIError>!
|
||||
var inviteMemberEmailPersonalNoteReturnValue: Result<Void, HealthChatAPIError>! {
|
||||
get { inviteMemberEmailPersonalNoteReturnValueLock.withLock { inviteMemberEmailPersonalNoteUnderlyingReturnValue } }
|
||||
set { inviteMemberEmailPersonalNoteReturnValueLock.withLock { inviteMemberEmailPersonalNoteUnderlyingReturnValue = newValue } }
|
||||
}
|
||||
nonisolated(unsafe) var inviteMemberEmailPersonalNoteClosure: ((String, String) async -> Result<Void, HealthChatAPIError>)?
|
||||
|
||||
@concurrent func inviteMember(email: String, personalNote: String) async -> Result<Void, HealthChatAPIError> {
|
||||
inviteMemberEmailPersonalNoteCallsCountLock.withLock { inviteMemberEmailPersonalNoteUnderlyingCallsCount += 1 }
|
||||
inviteMemberEmailPersonalNoteReceivedArguments = (email: email, personalNote: personalNote)
|
||||
inviteMemberEmailPersonalNoteReceivedInvocationsLock.withLock { inviteMemberEmailPersonalNoteUnderlyingReceivedInvocations.append((email: email, personalNote: personalNote)) }
|
||||
if let inviteMemberEmailPersonalNoteClosure = inviteMemberEmailPersonalNoteClosure {
|
||||
return await inviteMemberEmailPersonalNoteClosure(email, personalNote)
|
||||
} else {
|
||||
return inviteMemberEmailPersonalNoteReturnValue
|
||||
}
|
||||
}
|
||||
}
|
||||
nonisolated class HealthChatOpenIDTokenProviderMock: HealthChatOpenIDTokenProviderProtocol, @unchecked Sendable {
|
||||
|
||||
//MARK: - validToken
|
||||
|
||||
private let validTokenCallsCountLock = NSLock()
|
||||
private nonisolated(unsafe) var validTokenUnderlyingCallsCount = 0
|
||||
var validTokenCallsCount: Int {
|
||||
get { validTokenCallsCountLock.withLock { validTokenUnderlyingCallsCount } }
|
||||
set { validTokenCallsCountLock.withLock { validTokenUnderlyingCallsCount = newValue } }
|
||||
}
|
||||
var validTokenCalled: Bool {
|
||||
return validTokenCallsCount > 0
|
||||
}
|
||||
|
||||
private let validTokenReturnValueLock = NSLock()
|
||||
private nonisolated(unsafe) var validTokenUnderlyingReturnValue: Result<String, HealthChatAPIError>!
|
||||
var validTokenReturnValue: Result<String, HealthChatAPIError>! {
|
||||
get { validTokenReturnValueLock.withLock { validTokenUnderlyingReturnValue } }
|
||||
set { validTokenReturnValueLock.withLock { validTokenUnderlyingReturnValue = newValue } }
|
||||
}
|
||||
nonisolated(unsafe) var validTokenClosure: (() async -> Result<String, HealthChatAPIError>)?
|
||||
|
||||
@concurrent func validToken() async -> Result<String, HealthChatAPIError> {
|
||||
validTokenCallsCountLock.withLock { validTokenUnderlyingCallsCount += 1 }
|
||||
if let validTokenClosure = validTokenClosure {
|
||||
return await validTokenClosure()
|
||||
} else {
|
||||
return validTokenReturnValue
|
||||
}
|
||||
}
|
||||
//MARK: - invalidateToken
|
||||
|
||||
private let invalidateTokenCallsCountLock = NSLock()
|
||||
private nonisolated(unsafe) var invalidateTokenUnderlyingCallsCount = 0
|
||||
var invalidateTokenCallsCount: Int {
|
||||
get { invalidateTokenCallsCountLock.withLock { invalidateTokenUnderlyingCallsCount } }
|
||||
set { invalidateTokenCallsCountLock.withLock { invalidateTokenUnderlyingCallsCount = newValue } }
|
||||
}
|
||||
var invalidateTokenCalled: Bool {
|
||||
return invalidateTokenCallsCount > 0
|
||||
}
|
||||
nonisolated(unsafe) var invalidateTokenClosure: (() -> Void)?
|
||||
|
||||
func invalidateToken() {
|
||||
invalidateTokenCallsCountLock.withLock { invalidateTokenUnderlyingCallsCount += 1 }
|
||||
invalidateTokenClosure?()
|
||||
}
|
||||
}
|
||||
nonisolated class HomeserverCapabilitiesProxyMock: HomeserverCapabilitiesProxyProtocol, @unchecked Sendable {
|
||||
|
||||
//MARK: - refresh
|
||||
|
||||
@@ -871,6 +871,28 @@ class ClientProxy: ClientProxyProtocol {
|
||||
}
|
||||
}
|
||||
|
||||
func requestOpenIDToken() async -> Result<OpenIDToken, ClientProxyError> {
|
||||
do {
|
||||
return try await .success(.init(rustValue: client.requestOpenidToken()))
|
||||
} catch {
|
||||
MXLog.error("Failed requesting OpenID token with error: \(error)")
|
||||
return .failure(.sdkError(error))
|
||||
}
|
||||
}
|
||||
|
||||
func homeserverCredentials() -> Result<HomeserverCredentials, ClientProxyError> {
|
||||
do {
|
||||
let session = try client.session()
|
||||
guard let homeserverURL = URL(string: session.homeserverUrl) else {
|
||||
return .failure(.invalidServerName)
|
||||
}
|
||||
return .success(.init(homeserverURL: homeserverURL, accessToken: session.accessToken))
|
||||
} catch {
|
||||
MXLog.error("Failed retrieving the session's credentials with error: \(error)")
|
||||
return .failure(.sdkError(error))
|
||||
}
|
||||
}
|
||||
|
||||
func roomDirectorySearchProxy() -> RoomDirectorySearchProxyProtocol {
|
||||
RoomDirectorySearchProxy(roomDirectorySearch: client.roomDirectorySearch())
|
||||
}
|
||||
|
||||
@@ -261,6 +261,14 @@ protocol ClientProxyProtocol: AnyObject {
|
||||
|
||||
func profile(for userID: String) async -> Result<UserProfile, ClientProxyError>
|
||||
|
||||
/// An OpenID token for authenticating against services that trust the homeserver (MSC1961).
|
||||
/// Short-lived — cache per call site.
|
||||
func requestOpenIDToken() async -> Result<OpenIDToken, ClientProxyError>
|
||||
|
||||
/// The session's REST credentials for client-server API calls the SDK doesn't cover.
|
||||
/// The access token can rotate after a refresh, so re-read for every request.
|
||||
func homeserverCredentials() -> Result<HomeserverCredentials, ClientProxyError>
|
||||
|
||||
func roomDirectorySearchProxy() -> RoomDirectorySearchProxyProtocol
|
||||
|
||||
func resolveRoomAlias(_ alias: String) async -> Result<ResolvedRoomAlias, ClientProxyError>
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
//
|
||||
// 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 Foundation
|
||||
|
||||
/// The session's REST credentials for direct client-server API calls that the SDK doesn't cover.
|
||||
/// The access token can rotate after a refresh, so re-read these for every request and never persist them.
|
||||
nonisolated struct HomeserverCredentials: Equatable {
|
||||
let homeserverURL: URL
|
||||
let accessToken: String
|
||||
}
|
||||
|
||||
extension HomeserverCredentials: CustomStringConvertible {
|
||||
var description: String {
|
||||
"HomeserverCredentials(homeserverURL: \(homeserverURL), accessToken: <redacted>)"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
//
|
||||
// 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 Foundation
|
||||
import MatrixRustSDK
|
||||
|
||||
/// An OpenID token that third-party services can use to verify the user's identity
|
||||
/// against the homeserver (MSC1961). Short-lived — cache per call site.
|
||||
nonisolated struct OpenIDToken: Equatable {
|
||||
let accessToken: String
|
||||
let matrixServerName: String
|
||||
/// The interval after which the token expires, relative to when it was requested.
|
||||
let expiresIn: TimeInterval
|
||||
|
||||
init(accessToken: String, matrixServerName: String, expiresIn: TimeInterval) {
|
||||
self.accessToken = accessToken
|
||||
self.matrixServerName = matrixServerName
|
||||
self.expiresIn = expiresIn
|
||||
}
|
||||
|
||||
init(rustValue: MatrixRustSDK.OpenIdToken) {
|
||||
accessToken = rustValue.accessToken
|
||||
matrixServerName = rustValue.matrixServerName
|
||||
expiresIn = TimeInterval(rustValue.expiresInSeconds)
|
||||
}
|
||||
}
|
||||
|
||||
extension OpenIDToken: CustomStringConvertible {
|
||||
var description: String {
|
||||
"OpenIDToken(matrixServerName: \(matrixServerName), accessToken: <redacted>)"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
//
|
||||
// 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 Foundation
|
||||
|
||||
/// A user's profile including the HealthChat extended profile fields (MSC4133),
|
||||
/// as returned by `GET /_matrix/client/v3/profile/{userId}`.
|
||||
nonisolated struct ExtendedProfile: Equatable {
|
||||
let displayName: String?
|
||||
let avatarURL: URL?
|
||||
let title: String?
|
||||
let firstName: String?
|
||||
let lastName: String?
|
||||
let specialty: String?
|
||||
let practiceName: String?
|
||||
let furtherInfo: String?
|
||||
let businessEmail: String?
|
||||
let businessTel: String?
|
||||
let street: String?
|
||||
let houseNumber: String?
|
||||
let postalCode: String?
|
||||
let city: String?
|
||||
let website: String?
|
||||
|
||||
/// Whether any of the HealthChat-specific fields carry a value.
|
||||
var hasContent: Bool {
|
||||
[title, firstName, lastName, specialty, practiceName, furtherInfo,
|
||||
businessEmail, businessTel, street, houseNumber, postalCode, city, website]
|
||||
.contains { $0 != nil }
|
||||
}
|
||||
|
||||
/// The postal address in the same "street nr, plz city" format the web client uses.
|
||||
var formattedAddress: String? {
|
||||
let streetLine = [street, houseNumber].compactMap { $0 }.joined(separator: " ")
|
||||
let cityLine = [postalCode, city].compactMap { $0 }.joined(separator: " ")
|
||||
let lines = [streetLine, cityLine].filter { !$0.isEmpty }
|
||||
return lines.isEmpty ? nil : lines.joined(separator: ", ")
|
||||
}
|
||||
}
|
||||
|
||||
extension ExtendedProfile: Decodable {
|
||||
private struct StringKey: CodingKey {
|
||||
let stringValue: String
|
||||
var intValue: Int? {
|
||||
nil
|
||||
}
|
||||
|
||||
init(stringValue: String) {
|
||||
self.stringValue = stringValue
|
||||
}
|
||||
|
||||
init?(intValue: Int) {
|
||||
nil
|
||||
}
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: StringKey.self)
|
||||
|
||||
// Presence of the canonical key suppresses the legacy key even when its value is
|
||||
// empty, so a field the user cleared isn't resurrected by stale legacy data.
|
||||
// Empty and non-string values read as nil.
|
||||
func value(_ canonicalKey: String, legacyKey: String? = nil) -> String? {
|
||||
for key in [canonicalKey, legacyKey].compactMap({ $0 }) {
|
||||
guard container.contains(StringKey(stringValue: key)) else { continue }
|
||||
let raw = try? container.decode(String.self, forKey: .init(stringValue: key))
|
||||
return raw.flatMap { $0.isEmpty ? nil : $0 }
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
displayName = value("displayname")
|
||||
avatarURL = value("avatar_url").flatMap(URL.init(string:))
|
||||
title = value("io.element.profile.title")
|
||||
firstName = value("io.element.profile.first_name")
|
||||
lastName = value("io.element.profile.last_name")
|
||||
specialty = value("io.element.profile.specialization")
|
||||
practiceName = value("io.element.profile.practice_name")
|
||||
furtherInfo = value("io.element.profile.further_info")
|
||||
businessEmail = value("io.element.profile.business_email")
|
||||
businessTel = value("io.element.profile.business_tel")
|
||||
street = value("io.element.profile.business_street", legacyKey: "io.element.profile.street")
|
||||
houseNumber = value("io.element.profile.business_street_nr", legacyKey: "io.element.profile.house_number")
|
||||
postalCode = value("io.element.profile.business_plz", legacyKey: "io.element.profile.postal_code")
|
||||
city = value("io.element.profile.business_city")
|
||||
website = value("io.element.profile.website")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
//
|
||||
// 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 Foundation
|
||||
|
||||
/// Reads profiles straight from the homeserver's profile endpoint, which — unlike the
|
||||
/// Rust SDK's `getProfile` — includes the extended profile fields (MSC4133).
|
||||
final class ExtendedProfileService: ExtendedProfileServiceProtocol {
|
||||
private let clientProxy: ClientProxyProtocol
|
||||
private let urlSession: URLSession
|
||||
|
||||
init(clientProxy: ClientProxyProtocol, urlSession: URLSession = .shared) {
|
||||
self.clientProxy = clientProxy
|
||||
self.urlSession = urlSession
|
||||
}
|
||||
|
||||
func profile(for userID: String) async -> Result<ExtendedProfile, ExtendedProfileServiceError> {
|
||||
guard case let .success(credentials) = clientProxy.homeserverCredentials() else {
|
||||
return .failure(.missingCredentials)
|
||||
}
|
||||
|
||||
let url = credentials.homeserverURL.appending(path: "_matrix/client/v3/profile/\(userID)")
|
||||
var request = URLRequest(url: url)
|
||||
request.setValue("Bearer \(credentials.accessToken)", forHTTPHeaderField: "Authorization")
|
||||
|
||||
let data: Data
|
||||
do {
|
||||
let (responseData, response) = try await urlSession.data(for: request)
|
||||
|
||||
guard let httpResponse = response as? HTTPURLResponse else {
|
||||
return .failure(.networkFailure)
|
||||
}
|
||||
guard httpResponse.statusCode == 200 else {
|
||||
MXLog.error("Failed fetching the extended profile for \(userID) with status: \(httpResponse.statusCode)")
|
||||
return .failure(.httpStatus(httpResponse.statusCode))
|
||||
}
|
||||
|
||||
data = responseData
|
||||
} catch {
|
||||
MXLog.error("Failed fetching the extended profile for \(userID) with error: \(error)")
|
||||
return .failure(.networkFailure)
|
||||
}
|
||||
|
||||
do {
|
||||
return try .success(JSONDecoder().decode(ExtendedProfile.self, from: data))
|
||||
} catch {
|
||||
MXLog.error("Failed decoding the extended profile for \(userID) with error: \(error)")
|
||||
return .failure(.decodingFailure)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
//
|
||||
// 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 Foundation
|
||||
|
||||
enum ExtendedProfileServiceError: Error, Equatable {
|
||||
case missingCredentials
|
||||
case networkFailure
|
||||
case decodingFailure
|
||||
case httpStatus(Int)
|
||||
}
|
||||
|
||||
// sourcery: AutoMockable
|
||||
protocol ExtendedProfileServiceProtocol {
|
||||
func profile(for userID: String) async -> Result<ExtendedProfile, ExtendedProfileServiceError>
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
//
|
||||
// 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 Foundation
|
||||
|
||||
/// A member record as returned by the HealthChat API (`MatrixUser` in the backend).
|
||||
/// Every field except the user ID is optional so additive backend changes never break decoding.
|
||||
nonisolated struct HealthChatMember: Identifiable, Equatable {
|
||||
let userID: String
|
||||
let displayName: String?
|
||||
let avatarURL: URL?
|
||||
let title: String?
|
||||
let firstName: String?
|
||||
let lastName: String?
|
||||
let specialty: String?
|
||||
let practiceName: String?
|
||||
let businessCity: String?
|
||||
let businessEmail: String?
|
||||
let businessTel: String?
|
||||
let furtherInfo: String?
|
||||
let street: String?
|
||||
let houseNumber: String?
|
||||
let postalCode: String?
|
||||
let website: String?
|
||||
let isApproved: Bool
|
||||
|
||||
var id: String {
|
||||
userID
|
||||
}
|
||||
}
|
||||
|
||||
extension HealthChatMember: Decodable {
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case userID = "userId"
|
||||
case avatarURL = "avatarMxcUrl"
|
||||
case displayName, title, firstName, lastName, specialty, practiceName
|
||||
case businessCity, businessEmail, businessTel, furtherInfo
|
||||
case street, houseNumber, postalCode, website, isApproved
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
|
||||
func value(_ key: CodingKeys) -> String? {
|
||||
let raw = try? container.decodeIfPresent(String.self, forKey: key)
|
||||
return (raw ?? nil).flatMap { $0.isEmpty ? nil : $0 }
|
||||
}
|
||||
|
||||
userID = try container.decode(String.self, forKey: .userID)
|
||||
displayName = value(.displayName)
|
||||
avatarURL = value(.avatarURL).flatMap(URL.init(string:))
|
||||
title = value(.title)
|
||||
firstName = value(.firstName)
|
||||
lastName = value(.lastName)
|
||||
specialty = value(.specialty)
|
||||
practiceName = value(.practiceName)
|
||||
businessCity = value(.businessCity)
|
||||
businessEmail = value(.businessEmail)
|
||||
businessTel = value(.businessTel)
|
||||
furtherInfo = value(.furtherInfo)
|
||||
street = value(.street)
|
||||
houseNumber = value(.houseNumber)
|
||||
postalCode = value(.postalCode)
|
||||
website = value(.website)
|
||||
isApproved = try container.decodeIfPresent(Bool.self, forKey: .isApproved) ?? true
|
||||
}
|
||||
}
|
||||
|
||||
/// The response of `GET /api/usermatrix/users` (`MatrixUserSearchResult` in the backend).
|
||||
nonisolated struct HealthChatMembersResponse: Equatable {
|
||||
let users: [HealthChatMember]
|
||||
let isApproved: Bool
|
||||
/// The hint shown to accounts that haven't been approved yet.
|
||||
let pendingMessage: String?
|
||||
}
|
||||
|
||||
extension HealthChatMembersResponse: Decodable {
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case users, isApproved, pendingMessage
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
users = try container.decodeIfPresent([HealthChatMember].self, forKey: .users) ?? []
|
||||
isApproved = try container.decodeIfPresent(Bool.self, forKey: .isApproved) ?? true
|
||||
let message = try container.decodeIfPresent(String.self, forKey: .pendingMessage)
|
||||
pendingMessage = message.flatMap { $0.isEmpty ? nil : $0 }
|
||||
}
|
||||
}
|
||||
|
||||
/// A partial profile update for `PUT /api/usermatrix/profile` (`UpdateProfileRequest` in the backend).
|
||||
/// `nil` leaves a field unchanged, an empty string clears it.
|
||||
nonisolated struct HealthChatProfileUpdate: Encodable, Equatable {
|
||||
var firstName: String?
|
||||
var lastName: String?
|
||||
var title: String?
|
||||
var specialty: String?
|
||||
var practiceName: String?
|
||||
var businessCity: String?
|
||||
var businessEmail: String?
|
||||
var businessTel: String?
|
||||
var furtherInfo: String?
|
||||
var street: String?
|
||||
var houseNumber: String?
|
||||
var postalCode: String?
|
||||
var website: String?
|
||||
}
|
||||
|
||||
/// The response of `GET /api/usermatrix/profile/completeness`.
|
||||
nonisolated struct HealthChatProfileCompleteness: Equatable {
|
||||
let isComplete: Bool
|
||||
let missingFields: [String]
|
||||
let message: String?
|
||||
let hasProfilePhoto: Bool
|
||||
}
|
||||
|
||||
extension HealthChatProfileCompleteness: Decodable {
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case isComplete, missingFields, message, hasProfilePhoto
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
isComplete = try container.decodeIfPresent(Bool.self, forKey: .isComplete) ?? false
|
||||
missingFields = try container.decodeIfPresent([String].self, forKey: .missingFields) ?? []
|
||||
let rawMessage = try container.decodeIfPresent(String.self, forKey: .message)
|
||||
message = rawMessage.flatMap { $0.isEmpty ? nil : $0 }
|
||||
hasProfilePhoto = try container.decodeIfPresent(Bool.self, forKey: .hasProfilePhoto) ?? false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
//
|
||||
// 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 Foundation
|
||||
|
||||
/// The client for the HealthChat backend (member directory, own profile, invitations).
|
||||
/// Authenticates with an OpenID token that the backend verifies against the homeserver.
|
||||
final class HealthChatAPIService: HealthChatAPIServiceProtocol {
|
||||
private enum Method: String {
|
||||
case get = "GET"
|
||||
case put = "PUT"
|
||||
case post = "POST"
|
||||
}
|
||||
|
||||
private let baseURL: URL
|
||||
private let tokenProvider: HealthChatOpenIDTokenProviderProtocol
|
||||
private let urlSession: URLSession
|
||||
|
||||
init(baseURL: URL = HealthChatSettings.apiBaseURL,
|
||||
tokenProvider: HealthChatOpenIDTokenProviderProtocol,
|
||||
urlSession: URLSession = .shared) {
|
||||
self.baseURL = baseURL
|
||||
self.tokenProvider = tokenProvider
|
||||
self.urlSession = urlSession
|
||||
}
|
||||
|
||||
func members(searchQuery: String?) async -> Result<HealthChatMembersResponse, HealthChatAPIError> {
|
||||
var queryItems = [URLQueryItem]()
|
||||
if let searchQuery, !searchQuery.isEmpty {
|
||||
queryItems.append(.init(name: "search", value: searchQuery))
|
||||
}
|
||||
return await request(.get, path: "api/usermatrix/users", queryItems: queryItems)
|
||||
}
|
||||
|
||||
func ownProfile() async -> Result<HealthChatMember, HealthChatAPIError> {
|
||||
await request(.get, path: "api/usermatrix/profile")
|
||||
}
|
||||
|
||||
func updateOwnProfile(_ update: HealthChatProfileUpdate) async -> Result<Void, HealthChatAPIError> {
|
||||
guard let body = try? JSONEncoder().encode(update) else {
|
||||
return .failure(.decodingFailure)
|
||||
}
|
||||
return await send(.put, path: "api/usermatrix/profile", body: body).map { _ in }
|
||||
}
|
||||
|
||||
func profileCompleteness() async -> Result<HealthChatProfileCompleteness, HealthChatAPIError> {
|
||||
await request(.get, path: "api/usermatrix/profile/completeness")
|
||||
}
|
||||
|
||||
func inviteMember(email: String, personalNote: String) async -> Result<Void, HealthChatAPIError> {
|
||||
struct InvitationRequest: Encodable {
|
||||
let email: String
|
||||
let personalNote: String
|
||||
}
|
||||
|
||||
guard let body = try? JSONEncoder().encode(InvitationRequest(email: email, personalNote: personalNote)) else {
|
||||
return .failure(.decodingFailure)
|
||||
}
|
||||
return await send(.post, path: "api/usermatrix/invite", body: body).map { _ in }
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func request<Response: Decodable>(_ method: Method,
|
||||
path: String,
|
||||
queryItems: [URLQueryItem] = [],
|
||||
body: Data? = nil) async -> Result<Response, HealthChatAPIError> {
|
||||
switch await send(method, path: path, queryItems: queryItems, body: body) {
|
||||
case .success(let data):
|
||||
do {
|
||||
return try .success(JSONDecoder().decode(Response.self, from: data))
|
||||
} catch {
|
||||
MXLog.error("Failed decoding the HealthChat API response from \(path) with error: \(error)")
|
||||
return .failure(.decodingFailure)
|
||||
}
|
||||
case .failure(let error):
|
||||
return .failure(error)
|
||||
}
|
||||
}
|
||||
|
||||
private func send(_ method: Method,
|
||||
path: String,
|
||||
queryItems: [URLQueryItem] = [],
|
||||
body: Data? = nil) async -> Result<Data, HealthChatAPIError> {
|
||||
for attempt in 0...1 {
|
||||
let token: String
|
||||
switch await tokenProvider.validToken() {
|
||||
case .success(let validToken):
|
||||
token = validToken
|
||||
case .failure(let error):
|
||||
return .failure(error)
|
||||
}
|
||||
|
||||
var url = baseURL.appending(path: path)
|
||||
if !queryItems.isEmpty {
|
||||
url.append(queryItems: queryItems)
|
||||
}
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = method.rawValue
|
||||
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||
if let body {
|
||||
request.httpBody = body
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
}
|
||||
|
||||
let data: Data
|
||||
let response: URLResponse
|
||||
do {
|
||||
(data, response) = try await urlSession.data(for: request)
|
||||
} catch {
|
||||
MXLog.error("The HealthChat API request to \(path) failed with error: \(error)")
|
||||
return .failure(.networkFailure)
|
||||
}
|
||||
|
||||
guard let httpResponse = response as? HTTPURLResponse else {
|
||||
return .failure(.networkFailure)
|
||||
}
|
||||
|
||||
switch httpResponse.statusCode {
|
||||
case 200...299:
|
||||
return .success(data)
|
||||
case 401 where attempt == 0:
|
||||
// The cached OpenID token may have expired server-side — retry once with a fresh one.
|
||||
tokenProvider.invalidateToken()
|
||||
case 401:
|
||||
return .failure(.unauthorized)
|
||||
default:
|
||||
MXLog.error("The HealthChat API request to \(path) failed with status: \(httpResponse.statusCode)")
|
||||
return .failure(.httpStatus(httpResponse.statusCode))
|
||||
}
|
||||
}
|
||||
|
||||
return .failure(.unauthorized)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
//
|
||||
// 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 Foundation
|
||||
|
||||
enum HealthChatAPIError: Error, Equatable {
|
||||
case tokenAcquisitionFailed
|
||||
case unauthorized
|
||||
case networkFailure
|
||||
case decodingFailure
|
||||
case httpStatus(Int)
|
||||
}
|
||||
|
||||
// sourcery: AutoMockable
|
||||
protocol HealthChatAPIServiceProtocol {
|
||||
func members(searchQuery: String?) async -> Result<HealthChatMembersResponse, HealthChatAPIError>
|
||||
|
||||
func ownProfile() async -> Result<HealthChatMember, HealthChatAPIError>
|
||||
|
||||
func updateOwnProfile(_ update: HealthChatProfileUpdate) async -> Result<Void, HealthChatAPIError>
|
||||
|
||||
func profileCompleteness() async -> Result<HealthChatProfileCompleteness, HealthChatAPIError>
|
||||
|
||||
func inviteMember(email: String, personalNote: String) async -> Result<Void, HealthChatAPIError>
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
//
|
||||
// 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 Foundation
|
||||
|
||||
// sourcery: AutoMockable
|
||||
protocol HealthChatOpenIDTokenProviderProtocol {
|
||||
/// A valid OpenID access token, from cache when possible.
|
||||
func validToken() async -> Result<String, HealthChatAPIError>
|
||||
|
||||
/// Drops the cached token, e.g. after the API rejected it.
|
||||
func invalidateToken()
|
||||
}
|
||||
|
||||
final class HealthChatOpenIDTokenProvider: HealthChatOpenIDTokenProviderProtocol {
|
||||
private let clientProxy: ClientProxyProtocol
|
||||
private let dateProvider: () -> Date
|
||||
|
||||
/// Tokens this close to expiry are refreshed early, matching the web client's buffer.
|
||||
private static let expiryBuffer: TimeInterval = 60
|
||||
|
||||
private var cachedToken: (token: String, expiry: Date)?
|
||||
|
||||
init(clientProxy: ClientProxyProtocol, dateProvider: @escaping () -> Date = Date.init) {
|
||||
self.clientProxy = clientProxy
|
||||
self.dateProvider = dateProvider
|
||||
}
|
||||
|
||||
func validToken() async -> Result<String, HealthChatAPIError> {
|
||||
if let cachedToken, dateProvider().addingTimeInterval(Self.expiryBuffer) < cachedToken.expiry {
|
||||
return .success(cachedToken.token)
|
||||
}
|
||||
|
||||
switch await clientProxy.requestOpenIDToken() {
|
||||
case .success(let token):
|
||||
cachedToken = (token.accessToken, dateProvider().addingTimeInterval(token.expiresIn))
|
||||
return .success(token.accessToken)
|
||||
case .failure:
|
||||
return .failure(.tokenAcquisitionFailed)
|
||||
}
|
||||
}
|
||||
|
||||
func invalidateToken() {
|
||||
cachedToken = nil
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user