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)))
}
}
}
}