sdk/communication/AzureCommunicationChat/Source/ChatClient.swift (335 lines of code) (raw):

// -------------------------------------------------------------------------- // // Copyright (c) Microsoft Corporation. All rights reserved. // // The MIT License (MIT) // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the ""Software""), to // deal in the Software without restriction, including without limitation the // rights to use, copy, modify, merge, publish, distribute, sublicense, and/or // sell copies of the Software, and to permit persons to whom the Software is // furnished to do so, subject to the following conditions: // // The above copyright notice and this permission notice shall be included in // all copies or substantial portions of the Software. // // THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS // IN THE SOFTWARE. // // -------------------------------------------------------------------------- import AzureCommunicationCommon import AzureCore import Foundation /// ChatClient class for ChatThread operations. public class ChatClient { // MARK: Properties private let endpoint: String private let credential: CommunicationTokenCredential private let options: AzureCommunicationChatClientOptions private let service: Chat private var signalingClient: CommunicationSignalingClient? private var signalingClientStarted: Bool = false private var realTimeNotificationConnectedHandler: TrouterEventHandler? private var realTimeNotificationDisconnectedHandler: TrouterEventHandler? private var pushNotificationClient: PushNotificationClient? internal var registrationId: String public weak var pushNotificationKeyStorage: PushNotificationKeyStorage? // MARK: Initializers /// Create a ChatClient. /// - Parameters: /// - endpoint: The Communication Services endpoint. /// - credential: The user credential. /// - userOptions: Options used to configure the client. public init( endpoint: String, credential: CommunicationTokenCredential, withOptions userOptions: AzureCommunicationChatClientOptions ) throws { self.endpoint = endpoint self.credential = credential self.registrationId = UUID().uuidString guard let endpointUrl = URL(string: endpoint) else { throw AzureError.client("Unable to form base URL.") } // If applicationId is not provided bundle identifier will be used // Instead set the default application id to be an empty string var options: AzureCommunicationChatClientOptions = userOptions if userOptions.telemetryOptions.applicationId == nil { let apiVersion = AzureCommunicationChatClientOptions.ApiVersion(userOptions.apiVersion) let telemetryOptions = TelemetryOptions( telemetryDisabled: userOptions.telemetryOptions.telemetryDisabled, applicationId: "" ) options = AzureCommunicationChatClientOptions( apiVersion: apiVersion, logger: userOptions.logger, telemetryOptions: telemetryOptions, transportOptions: userOptions.transportOptions, dispatchQueue: userOptions.dispatchQueue, signalingErrorHandler: userOptions.signalingErrorHandler ) } self.options = options // Internal options do not use the CommunicationSignalingErrorHandler let internalOptions = AzureCommunicationChatClientOptionsInternal( apiVersion: AzureCommunicationChatClientOptionsInternal.ApiVersion(options.apiVersion), logger: options.logger, telemetryOptions: options.telemetryOptions, transportOptions: options.transportOptions, dispatchQueue: options.dispatchQueue ) let communicationCredential = TokenCredentialAdapter(credential) let authPolicy = BearerTokenCredentialPolicy(credential: communicationCredential, scopes: []) let client = try ChatClientInternal( endpoint: endpointUrl, authPolicy: authPolicy, withOptions: internalOptions ) self.service = client.chat } // MARK: Private Methods /// Converts [ChatParticipant] to [ChatParticipantInternal] for internal use. /// - Parameter chatParticipants: The array of ChatParticipants. /// - Returns: An array of ChatParticipants. private func convert(chatParticipants: [ChatParticipant]?) throws -> [ChatParticipantInternal]? { guard let participants = chatParticipants else { return nil } return try participants.map { participant -> ChatParticipantInternal in let identifierModel = try IdentifierSerializer.serialize(identifier: participant.id) return ChatParticipantInternal( communicationIdentifier: identifierModel, displayName: participant.displayName, shareHistoryTime: participant.shareHistoryTime ) } } // MARK: Public Methods /// Create a ChatThreadClient for the ChatThread with id threadId. /// - Parameters: /// - threadId: The threadId. public func createClient(forThread threadId: String) throws -> ChatThreadClient { return try ChatThreadClient( endpoint: endpoint, credential: credential, threadId: threadId, withOptions: options ) } /// Create a new ChatThread. /// - Parameters: /// - thread: Request for creating a chat thread with the topic and optional members to add. /// - options: Create chat thread options. /// - completionHandler: A completion handler that receives a ChatThreadClient on success. public func create( thread: CreateChatThreadRequest, withOptions options: CreateChatThreadOptions? = nil, completionHandler: @escaping HTTPResultHandler<CreateChatThreadResult> ) { // Set the repeatabilityRequestId if it is not provided let requestOptions = ((options?.repeatabilityRequestId) != nil) ? options : CreateChatThreadOptions( repeatabilityRequestId: UUID().uuidString, clientRequestId: options?.clientRequestId, cancellationToken: options?.cancellationToken, dispatchQueue: options?.dispatchQueue, context: options?.context ) do { // Convert ChatParticipant to ChatParticipantInternal let participants = try convert(chatParticipants: thread.participants) // Convert to CreateChatThreadRequestInternal let request = CreateChatThreadRequestInternal( topic: thread.topic, participants: participants ) service.create(chatThread: request, withOptions: requestOptions) { result, httpResponse in switch result { case let .success(chatThreadResult): do { let threadResult = try CreateChatThreadResult(from: chatThreadResult) completionHandler(.success(threadResult), httpResponse) } catch { let azureError = AzureError.client(error.localizedDescription, error) completionHandler(.failure(azureError), httpResponse) } case let .failure(error): completionHandler(.failure(error), httpResponse) } } } catch { // Return error from converting participants let azureError = AzureError.client("Failed to construct create thread request.", error) completionHandler(.failure(azureError), nil) } } /// Gets the list of ChatThreads for the user. /// - Parameters: /// - options: List chat threads options. /// - completionHandler: A completion handler that receives the list of chat thread items on success. public func listThreads( withOptions options: ListChatThreadsOptions? = nil, completionHandler: @escaping HTTPResultHandler<PagedCollection<ChatThreadItem>> ) { service.listChatThreads(withOptions: options) { result, httpResponse in switch result { case let .success(chatThreads): completionHandler(.success(chatThreads), httpResponse) case let .failure(error): completionHandler(.failure(error), httpResponse) } } } /// Delete the ChatThread with id chatThreadId. /// - Parameters: /// - threadId: The chat thread id. /// - options: Delete chat thread options. /// - completionHandler: A completion handler. public func delete( thread threadId: String, withOptions options: DeleteChatThreadOptions? = nil, completionHandler: @escaping HTTPResultHandler<Void> ) { service.deleteChatThread(chatThreadId: threadId, withOptions: options) { result, httpResponse in switch result { case .success: completionHandler(.success(()), httpResponse) case let .failure(error): completionHandler(.failure(error), httpResponse) } } } /// Start receiving realtime notifications. /// Call this function before subscribing to any event. /// - Parameter completionHandler: Called when starting notifications has completed. public func startRealTimeNotifications(completionHandler: @escaping (Result<Void, AzureError>) -> Void) { guard signalingClientStarted == false else { completionHandler(.failure(AzureError.client("Realtime notifications have already started."))) return } // Retrieve the access token credential.token { accessToken, error in do { guard let token = accessToken?.token else { throw AzureError.client("Failed to get token from credential.", error) } let tokenProvider = CommunicationSkypeTokenProvider( token: token, credential: self.credential, tokenRefreshHandler: { stopSignalingClient, error in // Unable to refresh the token, stop the connection if stopSignalingClient { self.signalingClient?.stop() self.signalingClientStarted = false self.options .signalingErrorHandler?( .failedToRefreshToken( "Unable to get valid token for realtime-notifications, stopping notifications." ) ) return } // Token is invalid, attempting to refresh token self.options.logger.error("Failed to get valid token. \(error ?? "")") self.options.logger.warning("Attempting to refresh token for realtime-notifications.") } ) // Initialize the signaling client let signalingClient = try CommunicationSignalingClient( communicationSkypeTokenProvider: tokenProvider, logger: self.options.logger ) self.signalingClient = signalingClient // Configure the signaling client signalingClient.configure(token: token, endpoint: self.endpoint) { result in switch result { case .success(): // After successful configuration, set the handlers if let handler = self.realTimeNotificationConnectedHandler { signalingClient.on(event: ChatEventId.realTimeNotificationConnected, handler: handler) } if let handler = self.realTimeNotificationDisconnectedHandler { signalingClient.on(event: ChatEventId.realTimeNotificationDisconnected, handler: handler) } // Start the signaling client only after successful configuration self.signalingClientStarted = true signalingClient.start() completionHandler(.success(())) case .failure(let error): completionHandler(.failure(error)) } } } catch { let azureError = AzureError.client("Failed to start realtime notifications.", error) completionHandler(.failure(azureError)) } } } /// Stop receiving realtime notifications. /// This function would unsubscribe to all events. public func stopRealTimeNotifications() { guard let signalingClient = signalingClient else { options.logger.warning("Signaling client is not initialized, realtime notifications have not been started.") return } signalingClientStarted = false signalingClient.stop() } /// Subscribe to chat events. /// - Parameters: /// - event: The chat event to subsribe to. /// - handler: The handler for the chat event. public func register( event: ChatEventId, handler: @escaping TrouterEventHandler ) { guard let signalingClient = signalingClient else { if event == ChatEventId.realTimeNotificationConnected { realTimeNotificationConnectedHandler = handler return } if event == ChatEventId.realTimeNotificationDisconnected { realTimeNotificationDisconnectedHandler = handler return } options.logger .warning( "Signaling client is not initialized, cannot register handler." ) return } if !signalingClientStarted, event != ChatEventId.realTimeNotificationConnected, event != ChatEventId.realTimeNotificationDisconnected { options.logger .warning( "Signaling client is not started, cannot register handler. Ensure startRealtimeNotifications() is called first." ) return } signalingClient.on(event: event, handler: handler) } /// Unsubscribe to chat events. /// - Parameters: /// - event: The chat event to unsubsribe from. public func unregister( event: ChatEventId ) { guard let signalingClient = signalingClient else { options.logger .warning( "Signaling client is not initialized, cannot unregister handler. Ensure startRealtimeNotifications() is called first." ) return } switch event { case .realTimeNotificationConnected: realTimeNotificationConnectedHandler = nil case .realTimeNotificationDisconnected: realTimeNotificationDisconnectedHandler = nil default: signalingClient.off(event: event) } } /// Start push notifications. Receiving of notifications can be expected after successfully registering. /// - Parameters: /// - deviceToken: APNS push token. /// - completionHandler: Success indicates request to register for notifications has been received. public func startPushNotifications( deviceToken: String, completionHandler: @escaping (Result<HTTPResponse?, AzureError>) -> Void ) { // If the PushNotification has already been started, return success to avoid unnecessary re-registration. // Theoretically this "pre-validation" mechanism can only work when app is alive. // In the case that the app is killed and relaunched, the chatClient will be initilized again so it will // inevitably perform a new registration. guard self.pushNotificationClient?.pushNotificationsStarted != true else { options.logger.warning("Warning: PushNotification has already been started.") completionHandler(.success(nil)) return } // Initialize the push notification client self.pushNotificationClient = PushNotificationClient( credential: credential, options: options, registrationId: registrationId ) guard let pushNotificationClient = pushNotificationClient else { completionHandler(.failure(AzureError.client("Failed to initialize PushNotificationClient."))) return } let encryptionKey: String // Persist the key if the Contoso intends to implement encryption if pushNotificationKeyStorage != nil { // Persist the key if the Contoso intends to implement encryption encryptionKey = generateEncryptionKey() do { try pushNotificationKeyStorage?.onPersistKey( encryptionKey, expiryTime: Date(timeIntervalSinceNow: 45 * 60) ) } catch { completionHandler(.failure(AzureError.client("Failed to persist the encryption key", error))) } } else { encryptionKey = "" } // After successful initialization, start push notifications pushNotificationClient.startPushNotifications( deviceRegistrationToken: deviceToken, encryptionKey: encryptionKey ) { result in switch result { case let .success(response): completionHandler(.success(response)) case let .failure(error): self.options.logger .error("Failed to start push notifications with error: \(error.localizedDescription)") completionHandler(.failure(AzureError.client("Failed to start push notifications", error))) } } } /// Stop push notifications. /// - Parameter completionHandler: Success indicates push notifications have been stopped. public func stopPushNotifications( completionHandler: @escaping (Result<HTTPResponse?, AzureError>) -> Void ) { // Report an error if pushNotificationClient doesn't exist guard let pushNotificationClient = pushNotificationClient else { completionHandler(.failure( AzureError .client( "PushNotificationClient is not initialized, cannot stop push notificaitons. Ensure startPushNotifications() is called first." ) )) return } // If PushNotification has already been stopped, return success and add a warning. guard pushNotificationClient.pushNotificationsStarted == true else { options.logger.warning("Warning: PushNotification has already been stopped.") completionHandler(.success(nil)) return } // Stop push notification pushNotificationClient.stopPushNotifications { result in switch result { case let .success(response): let refreshedRegistrationId = UUID().uuidString self.registrationId = refreshedRegistrationId completionHandler(.success(response)) case let .failure(error): self.options.logger .error("Failed to stop push notifications with error: \(error.localizedDescription)") completionHandler(.failure(AzureError.client("Failed to stop push notifications", error))) } } } }