Files
sodalive-ios/SodaLive/Sources/V2/Main/Chat/UserCreatorChat/WebSocket/WebSocketChatClient.swift

387 lines
11 KiB
Swift

import Foundation
final class WebSocketChatClient: NSObject {
private var session: URLSession?
private var task: URLSessionWebSocketTask?
private var heartbeatTimer: Timer?
private var joinTimeoutWorkItem: DispatchWorkItem?
private var roomId: Int = 0
private var isClosing = false
private let joinTimeout: TimeInterval = 15
private(set) var state: UserCreatorChatSocketState = .disconnected {
didSet {
onStateChange?(state)
}
}
var onStateChange: ((UserCreatorChatSocketState) -> Void)?
var onEvent: ((UserCreatorChatSocketEvent) -> Void)?
func connect(roomId: Int) {
performOnMain {
connectOnMain(roomId: roomId)
}
}
func sendText(_ text: String) -> String? {
return performOnMain {
sendTextOnMain(text)
}
}
func ping() {
performOnMain {
pingOnMain()
}
}
func close() {
performOnMain {
closeOnMain()
}
}
func closeWithoutLeave(notify: Bool = true) {
performOnMain {
closeWithoutLeaveOnMain(notify: notify)
}
}
private func connectOnMain(roomId: Int) {
guard roomId > 0 else { return }
closeWithoutLeaveOnMain(notify: false)
self.roomId = roomId
isClosing = false
state = .connecting
guard let url = makeWebSocketURL() else {
state = .disconnected
onEvent?(.closed(nil))
return
}
var request = URLRequest(url: url)
request.setValue("Bearer \(UserDefaults.string(forKey: UserDefaultsKey.token))", forHTTPHeaderField: "Authorization")
let delegateQueue = OperationQueue()
delegateQueue.maxConcurrentOperationCount = 1
let session = URLSession(configuration: .default, delegate: self, delegateQueue: delegateQueue)
let task = session.webSocketTask(with: request)
self.session = session
self.task = task
task.resume()
receiveLoop(for: task)
}
private func sendTextOnMain(_ text: String) -> String? {
guard state == .joined, roomId > 0 else { return nil }
let requestId = UUID().uuidString
send(type: .sendText, requestId: requestId, payload: ["textMessage": text])
return requestId
}
private func pingOnMain() {
guard state == .joined, roomId > 0 else { return }
send(type: .ping, requestId: UUID().uuidString, payload: [:])
}
private func closeOnMain() {
isClosing = true
stopHeartbeat()
cancelJoinTimeout()
guard let task else {
state = .disconnected
return
}
if state == .joined, roomId > 0 {
send(type: .leaveRoom, requestId: UUID().uuidString, payload: [:]) { [weak self] _ in
self?.cancelTask(task)
}
} else {
cancelTask(task)
}
}
private func closeWithoutLeaveOnMain(notify: Bool) {
isClosing = true
stopHeartbeat()
cancelJoinTimeout()
if let task {
cancelTask(task)
} else {
state = .disconnected
if notify {
onEvent?(.closed(nil))
}
}
}
private func joinRoomIfPossible() {
guard state == .socketOpen, roomId > 0 else { return }
state = .joining
startJoinTimeout()
send(type: .joinRoom, requestId: UUID().uuidString, payload: [:]) { [weak self] error in
guard let error else { return }
self?.handleJoinFailure(message: error.localizedDescription)
}
}
private func send(
type: UserCreatorChatSocketMessageType,
requestId: String,
payload: [String: Any],
completion: ((Error?) -> Void)? = nil
) {
guard roomId > 0 else {
completion?(WebSocketChatClientSendError.invalidState)
return
}
guard type == .joinRoom || state == .joined else {
completion?(WebSocketChatClientSendError.invalidState)
return
}
let dictionary: [String: Any] = [
"type": type.rawValue,
"requestId": requestId,
"roomId": roomId,
"payload": payload
]
guard let data = try? JSONSerialization.data(withJSONObject: dictionary),
let json = String(data: data, encoding: .utf8) else {
completion?(WebSocketChatClientSendError.encodingFailed)
return
}
DEBUG_LOG(json)
guard let task else {
completion?(WebSocketChatClientSendError.taskUnavailable)
return
}
task.send(.string(json)) { error in
DispatchQueue.main.async {
if let error {
ERROR_LOG(error.localizedDescription)
}
completion?(error)
}
}
}
private func receiveLoop(for task: URLSessionWebSocketTask) {
task.receive { [weak self] result in
DispatchQueue.main.async {
guard let self, self.isCurrentTask(task) else { return }
switch result {
case .success(let message):
self.handle(message)
self.receiveLoop(for: task)
case .failure(let error):
self.finishCurrentTask(task, error: error)
}
}
}
}
private func handle(_ message: URLSessionWebSocketTask.Message) {
let rawText: String?
switch message {
case .string(let text):
rawText = text
case .data(let data):
rawText = String(data: data, encoding: .utf8)
@unknown default:
rawText = nil
}
guard let rawText else { return }
DEBUG_LOG(rawText)
guard let data = rawText.data(using: .utf8),
let envelope = try? JSONDecoder().decode(UserCreatorChatIncomingEnvelope.self, from: data) else {
return
}
switch envelope.type {
case UserCreatorChatSocketMessageType.joined.rawValue:
guard state == .joining else { return }
cancelJoinTimeout()
state = .joined
startHeartbeat()
onEvent?(.joined)
case UserCreatorChatSocketMessageType.sendAck.rawValue:
if let requestId = envelope.requestId {
onEvent?(.sendAck(requestId: requestId, message: envelope.payload?.message))
}
case UserCreatorChatSocketMessageType.message.rawValue:
if let message = envelope.payload?.message {
onEvent?(.message(message))
}
case UserCreatorChatSocketMessageType.pong.rawValue:
DEBUG_LOG("PONG")
onEvent?(.pong)
case UserCreatorChatSocketMessageType.error.rawValue:
if state != .joined {
cancelJoinTimeout()
}
let messageKey = envelope.payload?.messageKey
if let messageKey {
ERROR_LOG(messageKey)
}
onEvent?(.error(messageKey: messageKey))
default:
break
}
}
private func startHeartbeat() {
DispatchQueue.main.async { [weak self] in
guard let self, self.state == .joined else { return }
self.stopHeartbeat()
self.heartbeatTimer = Timer.scheduledTimer(withTimeInterval: 30, repeats: true) { [weak self] _ in
self?.ping()
}
}
}
private func startJoinTimeout() {
cancelJoinTimeout()
let workItem = DispatchWorkItem { [weak self] in
guard let self, self.state == .joining else { return }
self.joinTimeoutWorkItem = nil
self.handleJoinFailure(message: "JOINED timeout")
}
joinTimeoutWorkItem = workItem
DispatchQueue.main.asyncAfter(deadline: .now() + joinTimeout, execute: workItem)
}
private func cancelJoinTimeout() {
joinTimeoutWorkItem?.cancel()
joinTimeoutWorkItem = nil
}
private func handleJoinFailure(message: String?) {
guard state == .joining else { return }
if let message {
ERROR_LOG(message)
}
closeWithoutLeaveOnMain(notify: false)
onEvent?(.joinFailed(message: message))
}
private func stopHeartbeat() {
let invalidate = { [weak self] in
self?.heartbeatTimer?.invalidate()
self?.heartbeatTimer = nil
}
if Thread.isMainThread {
invalidate()
} else {
DispatchQueue.main.async(execute: invalidate)
}
}
private func cancelTask(_ task: URLSessionWebSocketTask) {
cancelJoinTimeout()
task.cancel(with: .normalClosure, reason: nil)
if isCurrentTask(task) {
self.task = nil
session?.invalidateAndCancel()
session = nil
state = .disconnected
}
}
private func finishCurrentTask(_ task: URLSessionWebSocketTask, error: Error?) {
guard isCurrentTask(task) else { return }
let shouldNotify = isClosing == false
stopHeartbeat()
cancelJoinTimeout()
self.task = nil
session?.invalidateAndCancel()
session = nil
state = .disconnected
if shouldNotify {
if let error {
ERROR_LOG(error.localizedDescription)
}
onEvent?(.closed(error))
}
}
private func isCurrentTask(_ task: URLSessionWebSocketTask) -> Bool {
self.task === task
}
private func makeWebSocketURL() -> URL? {
guard var components = URLComponents(string: BASE_URL), let scheme = components.scheme else {
return nil
}
components.scheme = scheme == "http" ? "ws" : "wss"
components.path = "/ws/v2/user-creator-chat"
components.query = nil
return components.url
}
private func performOnMain<T>(_ work: () -> T) -> T {
if Thread.isMainThread {
return work()
}
return DispatchQueue.main.sync(execute: work)
}
}
private enum WebSocketChatClientSendError: LocalizedError {
case invalidState
case encodingFailed
case taskUnavailable
var errorDescription: String? {
switch self {
case .invalidState:
return "WebSocket is not ready to send"
case .encodingFailed:
return "Failed to encode WebSocket message"
case .taskUnavailable:
return "WebSocket task is unavailable"
}
}
}
extension WebSocketChatClient: URLSessionWebSocketDelegate {
func urlSession(
_ session: URLSession,
webSocketTask: URLSessionWebSocketTask,
didOpenWithProtocol protocol: String?
) {
DispatchQueue.main.async { [weak self] in
guard let self, self.isCurrentTask(webSocketTask) else { return }
self.state = .socketOpen
self.joinRoomIfPossible()
}
}
func urlSession(
_ session: URLSession,
webSocketTask: URLSessionWebSocketTask,
didCloseWith closeCode: URLSessionWebSocketTask.CloseCode,
reason: Data?
) {
DispatchQueue.main.async { [weak self] in
self?.finishCurrentTask(webSocketTask, error: nil)
}
}
}