diff --git a/DemoApp/Sources/Components/Chat/DemoChatAdapter.swift b/DemoApp/Sources/Components/Chat/DemoChatAdapter.swift index 810583b69..46496c69e 100644 --- a/DemoApp/Sources/Components/Chat/DemoChatAdapter.swift +++ b/DemoApp/Sources/Components/Chat/DemoChatAdapter.swift @@ -5,7 +5,7 @@ import Combine import Foundation import struct StreamChat.ChannelId -import struct StreamChat.ChatChannel +import class StreamChat.ChatChannel import class StreamChat.ChatChannelController import protocol StreamChat.ChatChannelControllerDelegate import class StreamChat.ChatClient diff --git a/DemoApp/Sources/Models/TokenResponse.swift b/DemoApp/Sources/Models/TokenResponse.swift index ec692afd0..e04c74a95 100644 --- a/DemoApp/Sources/Models/TokenResponse.swift +++ b/DemoApp/Sources/Models/TokenResponse.swift @@ -3,9 +3,8 @@ // import Foundation -import StreamVideo -struct TokenResponse: Codable, ReflectiveStringConvertible { +struct TokenResponse: Codable { let userId: String let token: String let apiKey: String diff --git a/Package.swift b/Package.swift index 0b2ea2aba..bf19146b8 100644 --- a/Package.swift +++ b/Package.swift @@ -23,26 +23,33 @@ let package = Package( ], dependencies: [ .package(url: "https://github.com/apple/swift-protobuf.git", exact: "1.30.0"), - .package(url: "https://github.com/GetStream/stream-video-swift-webrtc.git", exact: "145.8.0") + .package(url: "https://github.com/GetStream/stream-video-swift-webrtc.git", exact: "145.8.0"), + .package(url: "https://github.com/GetStream/stream-core-swift.git", branch: "develop") ], targets: [ .target( name: "StreamVideo", dependencies: [ .product(name: "SwiftProtobuf", package: "swift-protobuf"), - .product(name: "StreamWebRTC", package: "stream-video-swift-webrtc") + .product(name: "StreamWebRTC", package: "stream-video-swift-webrtc"), + .product(name: "StreamCore", package: "stream-core-swift") ] ), .target( name: "StreamVideoSwiftUI", - dependencies: ["StreamVideo"], + dependencies: [ + "StreamVideo" + ], resources: [ .process("Resources") ] ), .target( name: "StreamVideoUIKit", - dependencies: ["StreamVideo", "StreamVideoSwiftUI"] + dependencies: [ + "StreamVideo", + "StreamVideoSwiftUI" + ] ) ] ) diff --git a/Scripts/generate.sh b/Scripts/generate.sh index 6bfe950f9..c764790c2 100755 --- a/Scripts/generate.sh +++ b/Scripts/generate.sh @@ -22,5 +22,21 @@ rm -rf "$PROJECT_ROOT/Sources/StreamVideo/OpenApi/generated/Models/"* go run . openapi generate-client --language swift --spec "$SOURCE_PATH/releases/v2/video-openapi-clientside.yaml" --output "$PROJECT_ROOT/Sources/StreamVideo/OpenApi/generated/" ) +# Shared OpenAPI types are provided by StreamCore. +rm -f \ + "$PROJECT_ROOT/Sources/StreamVideo/OpenApi/generated/APIHelper.swift" \ + "$PROJECT_ROOT/Sources/StreamVideo/OpenApi/generated/CodableHelper.swift" \ + "$PROJECT_ROOT/Sources/StreamVideo/OpenApi/generated/Extensions.swift" \ + "$PROJECT_ROOT/Sources/StreamVideo/OpenApi/generated/JSONDataEncoding.swift" \ + "$PROJECT_ROOT/Sources/StreamVideo/OpenApi/generated/Models.swift" \ + "$PROJECT_ROOT/Sources/StreamVideo/OpenApi/generated/OpenISO8601DateFormatter.swift" \ + "$PROJECT_ROOT/Sources/StreamVideo/OpenApi/generated/Models/APIError.swift" \ + "$PROJECT_ROOT/Sources/StreamVideo/OpenApi/generated/Models/ConnectUserDetailsRequest.swift" \ + "$PROJECT_ROOT/Sources/StreamVideo/OpenApi/generated/Models/CreateDeviceRequest.swift" \ + "$PROJECT_ROOT/Sources/StreamVideo/OpenApi/generated/Models/Device.swift" \ + "$PROJECT_ROOT/Sources/StreamVideo/OpenApi/generated/Models/ListDevicesResponse.swift" \ + "$PROJECT_ROOT/Sources/StreamVideo/OpenApi/generated/Models/ModelResponse.swift" \ + "$PROJECT_ROOT/Sources/StreamVideo/OpenApi/generated/Models/WSAuthMessageRequest.swift" + # format the generated code swiftformat Sources/StreamVideo/OpenApi/generated diff --git a/Scripts/generateCode.sh b/Scripts/generateCode.sh index 5c7c8e88a..5ad59df83 100755 --- a/Scripts/generateCode.sh +++ b/Scripts/generateCode.sh @@ -38,6 +38,22 @@ cp ${OPENAPI_GENERATED_CODE_ROOT}/tmp/OpenAPIClient/Classes/OpenAPIs/*.swift ${O cp ${OPENAPI_GENERATED_CODE_ROOT}/tmp/OpenAPIClient/Classes/OpenAPIs/APIs/DefaultAPI.swift ${OPENAPI_GENERATED_CODE_ROOT}/APIs/ mv ${OPENAPI_GENERATED_CODE_ROOT}/tmp/OpenAPIClient/Classes/OpenAPIs/Models ${OPENAPI_GENERATED_CODE_ROOT} +# Shared OpenAPI types are provided by StreamCore. +rm -f \ + "${OPENAPI_GENERATED_CODE_ROOT}/APIHelper.swift" \ + "${OPENAPI_GENERATED_CODE_ROOT}/CodableHelper.swift" \ + "${OPENAPI_GENERATED_CODE_ROOT}/Extensions.swift" \ + "${OPENAPI_GENERATED_CODE_ROOT}/JSONDataEncoding.swift" \ + "${OPENAPI_GENERATED_CODE_ROOT}/Models.swift" \ + "${OPENAPI_GENERATED_CODE_ROOT}/OpenISO8601DateFormatter.swift" \ + "${OPENAPI_GENERATED_CODE_ROOT}/Models/APIError.swift" \ + "${OPENAPI_GENERATED_CODE_ROOT}/Models/ConnectUserDetailsRequest.swift" \ + "${OPENAPI_GENERATED_CODE_ROOT}/Models/CreateDeviceRequest.swift" \ + "${OPENAPI_GENERATED_CODE_ROOT}/Models/Device.swift" \ + "${OPENAPI_GENERATED_CODE_ROOT}/Models/ListDevicesResponse.swift" \ + "${OPENAPI_GENERATED_CODE_ROOT}/Models/ModelResponse.swift" \ + "${OPENAPI_GENERATED_CODE_ROOT}/Models/WSAuthMessageRequest.swift" + # delete the tmp path rm -rf "${OPENAPI_GENERATED_CODE_ROOT}/tmp" diff --git a/Sources/StreamVideo/Call.swift b/Sources/StreamVideo/Call.swift index 27a395677..b4c0b58b8 100644 --- a/Sources/StreamVideo/Call.swift +++ b/Sources/StreamVideo/Call.swift @@ -5,6 +5,7 @@ import AVFoundation import Combine import Foundation +import StreamCore import StreamWebRTC /// Observable object that provides info about the call state, as well as methods for updating it. diff --git a/Sources/StreamVideo/CallKit/CallKitService.swift b/Sources/StreamVideo/CallKit/CallKitService.swift index f3f887e8a..bdef149e6 100644 --- a/Sources/StreamVideo/CallKit/CallKitService.swift +++ b/Sources/StreamVideo/CallKit/CallKitService.swift @@ -6,6 +6,7 @@ import AVFoundation import CallKit import Combine import Foundation +import StreamCore import StreamWebRTC /// Manages CallKit integration for VoIP calls. diff --git a/Sources/StreamVideo/CallState.swift b/Sources/StreamVideo/CallState.swift index 301bc6b5a..f5a8f89fd 100644 --- a/Sources/StreamVideo/CallState.swift +++ b/Sources/StreamVideo/CallState.swift @@ -4,6 +4,7 @@ import Combine import Foundation +import StreamCore @MainActor public class CallState: ObservableObject { diff --git a/Sources/StreamVideo/CallStateMachine/Stages/Call+Stage.swift b/Sources/StreamVideo/CallStateMachine/Stages/Call+Stage.swift index ab5b005a4..3c4f5d6fd 100644 --- a/Sources/StreamVideo/CallStateMachine/Stages/Call+Stage.swift +++ b/Sources/StreamVideo/CallStateMachine/Stages/Call+Stage.swift @@ -4,6 +4,7 @@ import Combine import Foundation +import StreamCore extension Call.StateMachine { /// Represents a stage in the call state machine. diff --git a/Sources/StreamVideo/DependencyInjection/InjectedValues.swift b/Sources/StreamVideo/DependencyInjection/InjectedValues.swift deleted file mode 100644 index 972d7707b..000000000 --- a/Sources/StreamVideo/DependencyInjection/InjectedValues.swift +++ /dev/null @@ -1,46 +0,0 @@ -// -// Copyright © 2026 Stream.io Inc. All rights reserved. -// - -import Foundation - -public protocol InjectionKey { - /// The associated type representing the type of the dependency injection key's value. - associatedtype Value - - /// The default value for the dependency injection key. - static var currentValue: Self.Value { get set } -} - -/// Provides access to injected dependencies. -public struct InjectedValues { - /// This is only used as an accessor to the computed properties within extensions of `InjectedValues`. - private nonisolated(unsafe) static var current = InjectedValues() - - /// A static subscript for updating the `currentValue` of `InjectionKey` instances. - public static subscript(key: K.Type) -> K.Value where K: InjectionKey { - get { key.currentValue } - set { key.currentValue = newValue } - } - - /// A static subscript accessor for updating and references dependencies directly. - public static subscript(_ keyPath: WritableKeyPath) -> T { - get { current[keyPath: keyPath] } - set { current[keyPath: keyPath] = newValue } - } -} - -@propertyWrapper -public struct Injected { - private let keyPath: WritableKeyPath - public var wrappedValue: T { - get { InjectedValues[keyPath] } - set { InjectedValues[keyPath] = newValue } - } - - public init(_ keyPath: WritableKeyPath) { - self.keyPath = keyPath - } -} - -extension Injected: Sendable where T: Sendable {} diff --git a/Sources/StreamVideo/Errors/Errors.swift b/Sources/StreamVideo/Errors/Errors.swift index ee5fa6a61..b16cbdb78 100644 --- a/Sources/StreamVideo/Errors/Errors.swift +++ b/Sources/StreamVideo/Errors/Errors.swift @@ -3,122 +3,11 @@ // import Foundation +import StreamCore -extension Stream_Video_Sfu_Models_Error: Error, ReflectiveStringConvertible {} - -/// A Client error. -public class ClientError: Error, CustomStringConvertible, @unchecked Sendable { - public struct Location: Equatable, Sendable, CustomStringConvertible { - public let file: String - public let line: Int - public var description: String { "{ file:\(file), line:\(line) }" } - } - - /// The file and line number which emitted the error. - public let location: Location? - - private let message: String? - - /// An underlying error. - public let underlyingError: Error? - - public let apiError: APIError? - - var errorDescription: String? { - if let apiError { - return apiError.message - } else { - return underlyingError.map(String.init(describing:)) - } - } - - /// Retrieve the localized description for this error. - public var localizedDescription: String { message ?? errorDescription ?? "" } - - public var description: String { - var result = "ClientError {" - result += " location:\(location)" - if let message { - result += " message:\(message)" - } - if let apiError { - result += ", apiError:\(apiError)" - } - if let underlyingError { - result += ", underlyingError:\(underlyingError)" - } - if let errorDescription { - result += ", errorDescription:\(errorDescription)" - } - result += " }" - return result - } - - /// A client error based on an external general error. - /// - Parameters: - /// - error: an external error. - /// - file: a file name source of an error. - /// - line: a line source of an error. - public init(with error: Error? = nil, _ file: StaticString = #fileID, _ line: UInt = #line) { - underlyingError = error - message = error?.localizedDescription ?? nil - location = .init(file: "\(file)", line: Int(line)) - if let aErr = error as? APIError { - apiError = aErr - } else { - apiError = nil - } - } - - /// An error based on a message. - /// - Parameters: - /// - message: an error message. - /// - file: a file name source of an error. - /// - line: a line source of an error. - public init(_ message: String, _ file: StaticString = #fileID, _ line: UInt = #line) { - self.message = message - location = .init(file: "\(file)", line: Int(line)) - underlyingError = nil - apiError = nil - } -} - -extension ClientError { - /// An unexpected error. - public final class Unexpected: ClientError, @unchecked Sendable {} - - /// An unknown error. - public final class Unknown: ClientError, @unchecked Sendable {} - - /// Networking error. - public final class NetworkError: ClientError, @unchecked Sendable {} - - /// Represents a network-related error indicating that the network is unavailable. - public final class NetworkNotAvailable: ClientError, @unchecked Sendable {} - - /// Permissions error. - public final class MissingPermissions: ClientError, @unchecked Sendable {} - - /// Invalid url error. - public final class InvalidURL: ClientError, @unchecked Sendable {} -} - -// This should probably live only in the test target since it's not "true" equatable -extension ClientError: Equatable { - public static func == (lhs: ClientError, rhs: ClientError) -> Bool { - type(of: lhs) == type(of: rhs) - && String(describing: lhs.underlyingError) == String(describing: rhs.underlyingError) - && String(describing: lhs.localizedDescription) == String(describing: rhs.localizedDescription) - } -} - -extension ClientError { - /// Returns `true` if underlaying error is `ErrorPayload` with code is inside invalid token codes range. - var isInvalidTokenError: Bool { - (underlyingError as? ErrorPayload)?.isInvalidTokenError == true - || apiError?.isTokenExpiredError == true - } -} +extension Stream_Video_Sfu_Models_Error: + Error, + ReflectiveStringConvertible {} extension Error { var isRateLimitError: Bool { @@ -158,5 +47,3 @@ extension ClosedRange where Bound == Int { struct APIErrorContainer: Codable { let error: APIError } - -extension APIError: Error {} diff --git a/Sources/StreamVideo/HTTPClient/HTTPUtils.swift b/Sources/StreamVideo/HTTPClient/HTTPUtils.swift index b78d0c072..604171feb 100644 --- a/Sources/StreamVideo/HTTPClient/HTTPUtils.swift +++ b/Sources/StreamVideo/HTTPClient/HTTPUtils.swift @@ -3,6 +3,7 @@ // import Foundation +import StreamCore extension URLRequest { var queryItems: [URLQueryItem] { diff --git a/Sources/StreamVideo/HTTPClient/InternetConnection.swift b/Sources/StreamVideo/HTTPClient/VideoInternetConnection.swift similarity index 73% rename from Sources/StreamVideo/HTTPClient/InternetConnection.swift rename to Sources/StreamVideo/HTTPClient/VideoInternetConnection.swift index 2973fa748..da7b3f6c8 100644 --- a/Sources/StreamVideo/HTTPClient/InternetConnection.swift +++ b/Sources/StreamVideo/HTTPClient/VideoInternetConnection.swift @@ -22,11 +22,23 @@ extension Notification { } } -/// An Internet Connection monitor. +/// StreamVideo's internet connection monitor. /// -/// Basically, it's a wrapper over legacy monitor based on `Reachability` (iOS 11 only) -/// and default monitor based on `Network`.`NWPathMonitor` (iOS 12+). -final class InternetConnection: @unchecked Sendable { +/// StreamVideo retains this implementation instead of using +/// `StreamCore.InternetConnection` directly to preserve its existing delivery +/// semantics: +/// +/// - Monitor updates are serialized onto `DispatchQueue.main` before `status` +/// changes. +/// - `statusPublisher` debounces updates by 100 milliseconds so brief path +/// changes do not trigger reconnection work. +/// - Status and availability changes use StreamVideo's historical notification +/// names. +/// +/// StreamCore exposes the same status model and `NWPathMonitor`-based +/// monitoring, but updates status on the monitor's queue, does not debounce its +/// publisher, and posts different notification names. +final class VideoInternetConnection: @unchecked Sendable { /// The current Internet connection status. @Published private(set) var status: InternetConnectionStatus { didSet { @@ -72,13 +84,13 @@ final class InternetConnection: @unchecked Sendable { } } -extension InternetConnection: InternetConnectionDelegate { +extension VideoInternetConnection: InternetConnectionDelegate { func internetConnectionStatusDidChange(status: InternetConnectionStatus) { subject.send(status) } } -private extension InternetConnection { +private extension VideoInternetConnection { func postNotification(_ name: Notification.Name, with status: InternetConnectionStatus) { notificationCenter.post( name: name, @@ -111,48 +123,9 @@ protocol InternetConnectionMonitor: AnyObject { func stop() } -// MARK: Internet Connection Subtypes - -/// The Internet connectivity status. -public enum InternetConnectionStatus: Equatable, Sendable { - /// Notification of an Internet connection has not begun. - case unknown - - /// The Internet is available with a specific `Quality` level. - case available(InternetConnectionQuality) - - /// The Internet is unavailable. - case unavailable -} - -/// The Internet connectivity status quality. -public enum InternetConnectionQuality: Equatable, Sendable { - /// The Internet connection is great (like Wi-Fi). - case great - - /// Internet connection uses an interface that is considered expensive, such as Cellular or a Personal Hotspot. - case expensive - - /// Internet connection uses Low Data Mode. - /// Recommendations for Low Data Mode: don't autoplay video, music (high-quality) or gifs (big files). - /// Supports only by iOS 13+ - case constrained -} - -extension InternetConnectionStatus { - /// Returns `true` if the internet connection is available, ignoring the quality of the connection. - public var isAvailable: Bool { - if case .available = self { - return true - } else { - return false - } - } -} - // MARK: - Internet Connection Monitor -extension InternetConnection { +extension VideoInternetConnection { /// The default Internet connection monitor for iOS 12+. /// It uses Apple Network API. class Monitor: InternetConnectionMonitor, @unchecked Sendable { @@ -214,24 +187,16 @@ extension InternetConnection { } } -/// A protocol defining the interface for internet connection monitoring. -public protocol InternetConnectionProtocol { - var status: InternetConnectionStatus { get } - - /// A publisher that emits the current internet connection status. - /// - /// This publisher never fails and continuously updates with the latest - /// connection status. - var statusPublisher: AnyPublisher { get } -} - -extension InternetConnection: InternetConnectionProtocol { +extension VideoInternetConnection: InternetConnectionProtocol { /// A publisher that emits the current internet connection status. /// /// This implementation uses a published property wrapper and erases the /// type to `AnyPublisher`. /// /// - Note: The publisher won't publish any duplicates. + /// - Important: Unlike `StreamCore.InternetConnection`, status updates are + /// debounced by 100 milliseconds to ignore brief connectivity changes + /// before StreamVideo starts reconnection work. public var statusPublisher: AnyPublisher { $status .debounce(for: .seconds(0.1), scheduler: DispatchQueue.main) @@ -240,13 +205,13 @@ extension InternetConnection: InternetConnectionProtocol { } } -extension InternetConnection: InjectionKey { +extension VideoInternetConnection: InjectionKey { /// The current value of the internet connection monitor. /// /// This property provides a default implementation of the /// `InternetConnection` with a default monitor. - public nonisolated(unsafe) static var currentValue: InternetConnectionProtocol = InternetConnection( - monitor: InternetConnection.Monitor() + public nonisolated(unsafe) static var currentValue: InternetConnectionProtocol = VideoInternetConnection( + monitor: VideoInternetConnection.Monitor() ) } @@ -256,7 +221,7 @@ extension InjectedValues { /// This property allows for dependency injection using the protocol type, /// providing more flexibility in testing and modular design. public var internetConnectionObserver: InternetConnectionProtocol { - get { Self[InternetConnection.self] } - set { Self[InternetConnection.self] = newValue } + get { Self[VideoInternetConnection.self] } + set { Self[VideoInternetConnection.self] = newValue } } } diff --git a/Sources/StreamVideo/Models/CallsQuery.swift b/Sources/StreamVideo/Models/CallsQuery.swift index d551af25b..21e7a97fe 100644 --- a/Sources/StreamVideo/Models/CallsQuery.swift +++ b/Sources/StreamVideo/Models/CallsQuery.swift @@ -3,6 +3,7 @@ // import Foundation +import StreamCore /// Defines a query to retrieve a list of calls from the server, with specified page size, sort parameters, /// filters and watch flag. diff --git a/Sources/StreamVideo/Models/CoordinatorModels.swift b/Sources/StreamVideo/Models/CoordinatorModels.swift index b1163db90..e0bffe6b4 100644 --- a/Sources/StreamVideo/Models/CoordinatorModels.swift +++ b/Sources/StreamVideo/Models/CoordinatorModels.swift @@ -3,6 +3,7 @@ // import Foundation +import StreamCore public struct FetchingLocationError: Error {} diff --git a/Sources/StreamVideo/Models/Member.swift b/Sources/StreamVideo/Models/Member.swift index 0cec03e6e..e4a91cf79 100644 --- a/Sources/StreamVideo/Models/Member.swift +++ b/Sources/StreamVideo/Models/Member.swift @@ -3,6 +3,7 @@ // import Foundation +import StreamCore /// Represents a member in the call. public struct Member: Identifiable, Equatable, Sendable, Codable { diff --git a/Sources/StreamVideo/Models/Token.swift b/Sources/StreamVideo/Models/Token.swift deleted file mode 100644 index f6db5ed71..000000000 --- a/Sources/StreamVideo/Models/Token.swift +++ /dev/null @@ -1,39 +0,0 @@ -// -// Copyright © 2026 Stream.io Inc. All rights reserved. -// - -import Foundation - -/// The type is designed to store the JWT and the user it is related to. -public struct UserToken: Codable, Equatable, ExpressibleByStringLiteral, Sendable { - public let rawValue: String - - /// Created a new `Token` instance. - /// - Parameter value: The JWT string value. It must be in valid format and contain `user_id` in payload. - public init(stringLiteral value: StringLiteralType) { - self.init(rawValue: value) - } - - /// Creates a `Token` instance from the provided `rawValue` if it's valid. - /// - Parameter rawValue: The token string in JWT format. - public init(rawValue: String) { - self.rawValue = rawValue - } - - public init(from decoder: Decoder) throws { - let container = try decoder.singleValueContainer() - - try self.init( - rawValue: container.decode(String.self) - ) - } -} - -extension ClientError { - public class InvalidToken: ClientError, @unchecked Sendable {} -} - -public extension UserToken { - - static let empty = UserToken(rawValue: "") -} diff --git a/Sources/StreamVideo/Models/User.swift b/Sources/StreamVideo/Models/User.swift index a44cd59b8..aa9849d52 100644 --- a/Sources/StreamVideo/Models/User.swift +++ b/Sources/StreamVideo/Models/User.swift @@ -3,72 +3,7 @@ // import Foundation - -/// Model for the user's info. -public struct User: Identifiable, Hashable, Sendable, Codable { - public let id: String - public let imageURL: URL? - public let role: String - public let type: UserAuthType - public let customData: [String: RawJSON] - - /// User's name that was provided when the object was created. It will be used when communicating - /// with the API and in cases where it doesn't make sense to override `nil` values with the - /// `non-nil` id. - public let originalName: String? - - /// A computed property that can be used for UI elements where you need to display user's identifier. - /// If a `name` value was provided on initialisation it will return it. Otherwise returns the `id`. - public var name: String { originalName ?? id } - - public init( - id: String, - name: String? = nil, - imageURL: URL? = nil, - role: String = "user", - customData: [String: RawJSON] = [:] - ) { - self.init( - id: id, - name: name, - imageURL: imageURL, - role: role, - type: .regular, - customData: customData - ) - } - - init( - id: String, - name: String? = nil, - imageURL: URL? = nil, - role: String = "user", - type: UserAuthType = .regular, - customData: [String: RawJSON] = [:] - ) { - self.id = id - originalName = name - self.imageURL = imageURL - self.role = role - self.type = type - self.customData = customData - } -} - -public extension User { - /// Creates a guest user with the provided id. - /// - Parameter userId: the id of the user. - /// - Returns: a guest `User`. - static func guest(_ userId: String) -> User { - User(id: userId, name: userId, type: .guest) - } - - /// Creates an anonymous user. - /// - Returns: an anonymous `User`. - static var anonymous: User { - User(id: "!anon", type: .anonymous) - } -} +import StreamCore public extension UserResponse { static func make(from id: String) -> UserResponse { @@ -84,9 +19,3 @@ public extension UserResponse { ) } } - -public enum UserAuthType: Sendable, Codable, Hashable { - case regular - case anonymous - case guest -} diff --git a/Sources/StreamVideo/Moderation/Adapters/Video/Moderation+VideoAdapter.swift b/Sources/StreamVideo/Moderation/Adapters/Video/Moderation+VideoAdapter.swift index 36d39ab0d..6e5c48040 100644 --- a/Sources/StreamVideo/Moderation/Adapters/Video/Moderation+VideoAdapter.swift +++ b/Sources/StreamVideo/Moderation/Adapters/Video/Moderation+VideoAdapter.swift @@ -4,6 +4,7 @@ import Combine import Foundation +import StreamCore extension Moderation { diff --git a/Sources/StreamVideo/OpenApi/URLSessionTransport.swift b/Sources/StreamVideo/OpenApi/URLSessionTransport.swift index 8969c0da3..116110420 100644 --- a/Sources/StreamVideo/OpenApi/URLSessionTransport.swift +++ b/Sources/StreamVideo/OpenApi/URLSessionTransport.swift @@ -108,7 +108,7 @@ final class URLSessionTransport: DefaultAPITransport, @unchecked Sendable { } do { - return try JSONDecoder.default.decode(APIError.self, from: data) + return try JSONDecoder.streamCore.decode(APIError.self, from: data) } catch { return ClientError.NetworkError(response.description) } diff --git a/Sources/StreamVideo/OpenApi/generated/APIHelper.swift b/Sources/StreamVideo/OpenApi/generated/APIHelper.swift deleted file mode 100644 index 5fc13cb9b..000000000 --- a/Sources/StreamVideo/OpenApi/generated/APIHelper.swift +++ /dev/null @@ -1,117 +0,0 @@ -// -// Copyright © 2026 Stream.io Inc. All rights reserved. -// - -import Foundation - -public enum APIHelper { - public static func rejectNil(_ source: [String: Any?]) -> [String: Any]? { - let destination = source.reduce(into: [String: Any]()) { result, item in - if let value = item.value { - result[item.key] = value - } - } - - if destination.isEmpty { - return nil - } - return destination - } - - public static func rejectNilHeaders(_ source: [String: Any?]) -> [String: String] { - source.reduce(into: [String: String]()) { result, item in - if let collection = item.value as? [Any?] { - result[item.key] = collection - .compactMap { value in convertAnyToString(value) } - .joined(separator: ",") - } else if let value: Any = item.value { - result[item.key] = convertAnyToString(value) - } - } - } - - public static func convertBoolToString(_ source: [String: Any]?) -> [String: Any]? { - guard let source = source else { - return nil - } - - return source.reduce(into: [String: Any]()) { result, item in - switch item.value { - case let x as Bool: - result[item.key] = x.description - default: - result[item.key] = item.value - } - } - } - - public static func convertAnyToString(_ value: Any?) -> String? { - guard let value = value else { return nil } - if let value = value as? any RawRepresentable { - return "\(value.rawValue)" - } else { - return "\(value)" - } - } - - public static func mapValueToPathItem(_ source: Any) -> Any { - if let collection = source as? [Any?] { - return collection - .compactMap { value in convertAnyToString(value) } - .joined(separator: ",") - } - return source - } - - /// maps all values from source to query parameters - /// - /// explode attribute is respected: collection values might be either joined or split up into separate key value pairs - public static func mapValuesToQueryItems(_ source: [String: (wrappedValue: Any?, isExplode: Bool)]) -> [URLQueryItem]? { - let destination = source.filter { $0.value.wrappedValue != nil }.reduce(into: [URLQueryItem]()) { result, item in - if let collection = item.value.wrappedValue as? [Any?] { - - let collectionValues: [String] = collection.compactMap { value in convertAnyToString(value) } - - if !item.value.isExplode { - result.append(URLQueryItem(name: item.key, value: collectionValues.joined(separator: ","))) - } else { - collectionValues - .forEach { value in - result.append(URLQueryItem(name: item.key, value: value)) - } - } - - } else if let value = item.value.wrappedValue { - result.append(URLQueryItem(name: item.key, value: convertAnyToString(value))) - } - } - - if destination.isEmpty { - return nil - } - return destination - } - - /// maps all values from source to query parameters - /// - /// collection values are always exploded - public static func mapValuesToQueryItems(_ source: [String: Any?]) -> [URLQueryItem]? { - let destination = source.filter { $0.value != nil }.reduce(into: [URLQueryItem]()) { result, item in - if let collection = item.value as? [Any?] { - collection - .compactMap { value in convertAnyToString(value) } - .forEach { value in - result.append(URLQueryItem(name: item.key, value: value)) - } - - } else if let value = item.value { - result.append(URLQueryItem(name: item.key, value: convertAnyToString(value))) - } - } - - if destination.isEmpty { - return nil - } - return destination - } -} diff --git a/Sources/StreamVideo/OpenApi/generated/APIs/DefaultAPI.swift b/Sources/StreamVideo/OpenApi/generated/APIs/DefaultAPI.swift index ca60b6f4b..dc87651f9 100644 --- a/Sources/StreamVideo/OpenApi/generated/APIs/DefaultAPI.swift +++ b/Sources/StreamVideo/OpenApi/generated/APIs/DefaultAPI.swift @@ -70,8 +70,8 @@ open class DefaultAPI: DefaultAPIEndpoints, @unchecked Sendable { basePath: String, transport: DefaultAPITransport, middlewares: [DefaultAPIClientMiddleware], - jsonDecoder: JSONDecoder = JSONDecoder.default, - jsonEncoder: JSONEncoder = JSONEncoder.default + jsonDecoder: JSONDecoder = JSONDecoder.streamCore, + jsonEncoder: JSONEncoder = JSONEncoder.streamCore ) { self.basePath = basePath self.transport = transport diff --git a/Sources/StreamVideo/OpenApi/generated/CodableHelper.swift b/Sources/StreamVideo/OpenApi/generated/CodableHelper.swift deleted file mode 100644 index 0e8c335f4..000000000 --- a/Sources/StreamVideo/OpenApi/generated/CodableHelper.swift +++ /dev/null @@ -1,48 +0,0 @@ -// -// Copyright © 2026 Stream.io Inc. All rights reserved. -// - -import Foundation - -open class CodableHelper { - private nonisolated(unsafe) static var customDateFormatter: DateFormatter? - private nonisolated(unsafe) static var defaultDateFormatter: DateFormatter = OpenISO8601DateFormatter() - - private nonisolated(unsafe) static var customJSONDecoder: JSONDecoder? - private nonisolated(unsafe) static var defaultJSONDecoder: JSONDecoder = { - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .formatted(CodableHelper.dateFormatter) - return decoder - }() - - private nonisolated(unsafe) static var customJSONEncoder: JSONEncoder? - private nonisolated(unsafe) static var defaultJSONEncoder: JSONEncoder = { - let encoder = JSONEncoder() - encoder.dateEncodingStrategy = .formatted(CodableHelper.dateFormatter) - encoder.outputFormatting = .prettyPrinted - return encoder - }() - - public static var dateFormatter: DateFormatter { - get { customDateFormatter ?? defaultDateFormatter } - set { customDateFormatter = newValue } - } - - public static var jsonDecoder: JSONDecoder { - get { customJSONDecoder ?? defaultJSONDecoder } - set { customJSONDecoder = newValue } - } - - public static var jsonEncoder: JSONEncoder { - get { customJSONEncoder ?? defaultJSONEncoder } - set { customJSONEncoder = newValue } - } - - open class func decode(_ type: T.Type, from data: Data) -> Swift.Result where T: Decodable { - Swift.Result { try jsonDecoder.decode(type, from: data) } - } - - open class func encode(_ value: T) -> Swift.Result where T: Encodable { - Swift.Result { try jsonEncoder.encode(value) } - } -} diff --git a/Sources/StreamVideo/OpenApi/generated/Extensions.swift b/Sources/StreamVideo/OpenApi/generated/Extensions.swift deleted file mode 100644 index 86816240a..000000000 --- a/Sources/StreamVideo/OpenApi/generated/Extensions.swift +++ /dev/null @@ -1,100 +0,0 @@ -// -// Copyright © 2026 Stream.io Inc. All rights reserved. -// - -import Foundation - -extension Bool: JSONEncodable { - func encodeToJSON() -> Any { self } -} - -extension Float: JSONEncodable { - func encodeToJSON() -> Any { self } -} - -extension Int: JSONEncodable { - func encodeToJSON() -> Any { self } -} - -extension Int32: JSONEncodable { - func encodeToJSON() -> Any { self } -} - -extension Int64: JSONEncodable { - func encodeToJSON() -> Any { self } -} - -extension Double: JSONEncodable { - func encodeToJSON() -> Any { self } -} - -extension Decimal: JSONEncodable { - func encodeToJSON() -> Any { self } -} - -extension String: JSONEncodable { - func encodeToJSON() -> Any { self } -} - -extension URL: JSONEncodable { - func encodeToJSON() -> Any { self } -} - -extension UUID: JSONEncodable { - func encodeToJSON() -> Any { self } -} - -extension RawRepresentable where RawValue: JSONEncodable { - func encodeToJSON() -> Any { rawValue } -} - -private func encodeIfPossible(_ object: T) -> Any { - if let encodableObject = object as? JSONEncodable { - return encodableObject.encodeToJSON() - } else { - return object - } -} - -extension Array: JSONEncodable { - func encodeToJSON() -> Any { - map(encodeIfPossible) - } -} - -extension Set: JSONEncodable { - func encodeToJSON() -> Any { - Array(self).encodeToJSON() - } -} - -extension Dictionary: JSONEncodable { - func encodeToJSON() -> Any { - var dictionary = [AnyHashable: Any]() - for (key, value) in self { - dictionary[key] = encodeIfPossible(value) - } - return dictionary - } -} - -extension Data: JSONEncodable { - func encodeToJSON() -> Any { - base64EncodedString(options: Data.Base64EncodingOptions()) - } -} - -extension Date: JSONEncodable { - func encodeToJSON() -> Any { - CodableHelper.dateFormatter.string(from: self) - } -} - -extension JSONEncodable where Self: Encodable { - func encodeToJSON() -> Any { - guard let data = try? CodableHelper.jsonEncoder.encode(self) else { - fatalError("Could not encode to json: \(self)") - } - return data.encodeToJSON() - } -} diff --git a/Sources/StreamVideo/OpenApi/generated/JSONDataEncoding.swift b/Sources/StreamVideo/OpenApi/generated/JSONDataEncoding.swift deleted file mode 100644 index 91ac85b09..000000000 --- a/Sources/StreamVideo/OpenApi/generated/JSONDataEncoding.swift +++ /dev/null @@ -1,49 +0,0 @@ -// -// Copyright © 2026 Stream.io Inc. All rights reserved. -// - -import Foundation - -public struct JSONDataEncoding { - - // MARK: Properties - - private static let jsonDataKey = "jsonData" - - // MARK: Encoding - - /// Creates a URL request by encoding parameters and applying them onto an existing request. - /// - /// - parameter urlRequest: The request to have parameters applied. - /// - parameter parameters: The parameters to apply. This should have a single key/value - /// pair with "jsonData" as the key and a Data object as the value. - /// - /// - throws: An `Error` if the encoding process encounters an error. - /// - /// - returns: The encoded request. - public func encode(_ urlRequest: URLRequest, with parameters: [String: Any]?) -> URLRequest { - var urlRequest = urlRequest - - guard let jsonData = parameters?[JSONDataEncoding.jsonDataKey] as? Data, !jsonData.isEmpty else { - return urlRequest - } - - if urlRequest.value(forHTTPHeaderField: "Content-Type") == nil { - urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type") - } - - urlRequest.httpBody = jsonData - - return urlRequest - } - - public static func encodingParameters(jsonData: Data?) -> [String: Any]? { - var returnedParams: [String: Any]? - if let jsonData = jsonData, !jsonData.isEmpty { - var params: [String: Any] = [:] - params[jsonDataKey] = jsonData - returnedParams = params - } - return returnedParams - } -} diff --git a/Sources/StreamVideo/OpenApi/generated/Models.swift b/Sources/StreamVideo/OpenApi/generated/Models.swift deleted file mode 100644 index dc8897e32..000000000 --- a/Sources/StreamVideo/OpenApi/generated/Models.swift +++ /dev/null @@ -1,86 +0,0 @@ -// -// Copyright © 2026 Stream.io Inc. All rights reserved. -// - -import Foundation - -protocol JSONEncodable { - func encodeToJSON() -> Any -} - -/// An enum where the last case value can be used as a default catch-all. -protocol CaseIterableDefaultsLast: Decodable & CaseIterable & RawRepresentable - where RawValue: Decodable, AllCases: BidirectionalCollection {} - -extension CaseIterableDefaultsLast { - /// Initializes an enum such that if a known raw value is found, then it is decoded. - /// Otherwise the last case is used. - /// - Parameter decoder: A decoder. - public init(from decoder: Decoder) throws { - if let value = try Self(rawValue: decoder.singleValueContainer().decode(RawValue.self)) { - self = value - } else if let lastValue = Self.allCases.last { - self = lastValue - } else { - throw DecodingError.valueNotFound( - Self.Type.self, - .init(codingPath: decoder.codingPath, debugDescription: "CaseIterableDefaultsLast") - ) - } - } -} - -/// A flexible type that can be encoded (`.encodeNull` or `.encodeValue`) -/// or not encoded (`.encodeNothing`). Intended for request payloads. -public enum NullEncodable: Hashable { - case encodeNothing - case encodeNull - case encodeValue(Wrapped) -} - -extension NullEncodable: Codable where Wrapped: Codable { - public init(from decoder: Decoder) throws { - let container = try decoder.singleValueContainer() - if let value = try? container.decode(Wrapped.self) { - self = .encodeValue(value) - } else if container.decodeNil() { - self = .encodeNull - } else { - self = .encodeNothing - } - } - - public func encode(to encoder: Encoder) throws { - var container = encoder.singleValueContainer() - switch self { - case .encodeNothing: return - case .encodeNull: try container.encodeNil() - case let .encodeValue(wrapped): try container.encode(wrapped) - } - } -} - -open class Response { - public let statusCode: Int - public let header: [String: String] - public let body: T - public let bodyData: Data? - - public init(statusCode: Int, header: [String: String], body: T, bodyData: Data?) { - self.statusCode = statusCode - self.header = header - self.body = body - self.bodyData = bodyData - } - - public convenience init(response: HTTPURLResponse, body: T, bodyData: Data?) { - let rawHeader = response.allHeaderFields - var header = [String: String]() - for (key, value) in rawHeader { - if let key = key.base as? String, let value = value as? String { - header[key] = value - } - } - self.init(statusCode: response.statusCode, header: header, body: body, bodyData: bodyData) - } -} diff --git a/Sources/StreamVideo/OpenApi/generated/Models/APIError.swift b/Sources/StreamVideo/OpenApi/generated/Models/APIError.swift deleted file mode 100644 index d92298fe3..000000000 --- a/Sources/StreamVideo/OpenApi/generated/Models/APIError.swift +++ /dev/null @@ -1,70 +0,0 @@ -// -// Copyright © 2026 Stream.io Inc. All rights reserved. -// - -import Foundation - -public final class APIError: @unchecked Sendable, Codable, JSONEncodable, Hashable, ReflectiveStringConvertible { - - public var code: Int - public var details: [Int] - public var duration: String - public var exceptionFields: [String: String]? - public var message: String - public var moreInfo: String - public var statusCode: Int - public var unrecoverable: Bool? - - public init( - code: Int, - details: [Int], - duration: String, - exceptionFields: [String: String]? = nil, - message: String, - moreInfo: String, - statusCode: Int, - unrecoverable: Bool? = nil - ) { - self.code = code - self.details = details - self.duration = duration - self.exceptionFields = exceptionFields - self.message = message - self.moreInfo = moreInfo - self.statusCode = statusCode - self.unrecoverable = unrecoverable - } - - public enum CodingKeys: String, CodingKey, CaseIterable { - case code - case details - case duration - case exceptionFields = "exception_fields" - case message - case moreInfo = "more_info" - case statusCode = "StatusCode" - case unrecoverable - } - - public static func == (lhs: APIError, rhs: APIError) -> Bool { - lhs.code == rhs.code && - lhs.details == rhs.details && - lhs.duration == rhs.duration && - lhs.exceptionFields == rhs.exceptionFields && - lhs.message == rhs.message && - lhs.moreInfo == rhs.moreInfo && - lhs.statusCode == rhs.statusCode && - lhs.unrecoverable == rhs.unrecoverable - } - - public func hash(into hasher: inout Hasher) { - hasher.combine(code) - hasher.combine(details) - hasher.combine(duration) - hasher.combine(exceptionFields) - hasher.combine(message) - hasher.combine(moreInfo) - hasher.combine(statusCode) - hasher.combine(unrecoverable) - } -} diff --git a/Sources/StreamVideo/OpenApi/generated/Models/AppUpdatedEvent.swift b/Sources/StreamVideo/OpenApi/generated/Models/AppUpdatedEvent.swift index 5f31b6342..5dca5f5e4 100644 --- a/Sources/StreamVideo/OpenApi/generated/Models/AppUpdatedEvent.swift +++ b/Sources/StreamVideo/OpenApi/generated/Models/AppUpdatedEvent.swift @@ -2,6 +2,7 @@ // Copyright © 2026 Stream.io Inc. All rights reserved. // +import StreamCore import Foundation public final class AppUpdatedEvent: @unchecked Sendable, Event, Codable, JSONEncodable, Hashable { diff --git a/Sources/StreamVideo/OpenApi/generated/Models/BlockedUserEvent.swift b/Sources/StreamVideo/OpenApi/generated/Models/BlockedUserEvent.swift index 94bad4257..0ee2f9aa3 100644 --- a/Sources/StreamVideo/OpenApi/generated/Models/BlockedUserEvent.swift +++ b/Sources/StreamVideo/OpenApi/generated/Models/BlockedUserEvent.swift @@ -3,6 +3,7 @@ // import Foundation +import StreamCore public final class BlockedUserEvent: @unchecked Sendable, Event, Codable, JSONEncodable, Hashable, WSCallEvent { diff --git a/Sources/StreamVideo/OpenApi/generated/Models/CallAcceptedEvent.swift b/Sources/StreamVideo/OpenApi/generated/Models/CallAcceptedEvent.swift index b77c32e3f..0802054b3 100644 --- a/Sources/StreamVideo/OpenApi/generated/Models/CallAcceptedEvent.swift +++ b/Sources/StreamVideo/OpenApi/generated/Models/CallAcceptedEvent.swift @@ -3,6 +3,7 @@ // import Foundation +import StreamCore public final class CallAcceptedEvent: @unchecked Sendable, Event, Codable, JSONEncodable, Hashable, WSCallEvent { diff --git a/Sources/StreamVideo/OpenApi/generated/Models/CallClosedCaptionsFailedEvent.swift b/Sources/StreamVideo/OpenApi/generated/Models/CallClosedCaptionsFailedEvent.swift index 27505d224..f6cf5f12a 100644 --- a/Sources/StreamVideo/OpenApi/generated/Models/CallClosedCaptionsFailedEvent.swift +++ b/Sources/StreamVideo/OpenApi/generated/Models/CallClosedCaptionsFailedEvent.swift @@ -3,6 +3,7 @@ // import Foundation +import StreamCore public final class CallClosedCaptionsFailedEvent: @unchecked Sendable, Event, Codable, JSONEncodable, Hashable, WSCallEvent { diff --git a/Sources/StreamVideo/OpenApi/generated/Models/CallClosedCaptionsStartedEvent.swift b/Sources/StreamVideo/OpenApi/generated/Models/CallClosedCaptionsStartedEvent.swift index 999225720..965963195 100644 --- a/Sources/StreamVideo/OpenApi/generated/Models/CallClosedCaptionsStartedEvent.swift +++ b/Sources/StreamVideo/OpenApi/generated/Models/CallClosedCaptionsStartedEvent.swift @@ -3,6 +3,7 @@ // import Foundation +import StreamCore public final class CallClosedCaptionsStartedEvent: @unchecked Sendable, Event, Codable, JSONEncodable, Hashable, WSCallEvent { diff --git a/Sources/StreamVideo/OpenApi/generated/Models/CallClosedCaptionsStoppedEvent.swift b/Sources/StreamVideo/OpenApi/generated/Models/CallClosedCaptionsStoppedEvent.swift index 194b98c92..05d217bf2 100644 --- a/Sources/StreamVideo/OpenApi/generated/Models/CallClosedCaptionsStoppedEvent.swift +++ b/Sources/StreamVideo/OpenApi/generated/Models/CallClosedCaptionsStoppedEvent.swift @@ -3,6 +3,7 @@ // import Foundation +import StreamCore public final class CallClosedCaptionsStoppedEvent: @unchecked Sendable, Event, Codable, JSONEncodable, Hashable, WSCallEvent { diff --git a/Sources/StreamVideo/OpenApi/generated/Models/CallCreatedEvent.swift b/Sources/StreamVideo/OpenApi/generated/Models/CallCreatedEvent.swift index d12748605..c73a0a253 100644 --- a/Sources/StreamVideo/OpenApi/generated/Models/CallCreatedEvent.swift +++ b/Sources/StreamVideo/OpenApi/generated/Models/CallCreatedEvent.swift @@ -3,6 +3,7 @@ // import Foundation +import StreamCore public final class CallCreatedEvent: @unchecked Sendable, Event, Codable, JSONEncodable, Hashable, WSCallEvent { diff --git a/Sources/StreamVideo/OpenApi/generated/Models/CallDeletedEvent.swift b/Sources/StreamVideo/OpenApi/generated/Models/CallDeletedEvent.swift index 977f2a4ad..90b65233e 100644 --- a/Sources/StreamVideo/OpenApi/generated/Models/CallDeletedEvent.swift +++ b/Sources/StreamVideo/OpenApi/generated/Models/CallDeletedEvent.swift @@ -3,6 +3,7 @@ // import Foundation +import StreamCore public final class CallDeletedEvent: @unchecked Sendable, Event, Codable, JSONEncodable, Hashable, WSCallEvent { diff --git a/Sources/StreamVideo/OpenApi/generated/Models/CallEndedEvent.swift b/Sources/StreamVideo/OpenApi/generated/Models/CallEndedEvent.swift index 5d6b07457..9089febea 100644 --- a/Sources/StreamVideo/OpenApi/generated/Models/CallEndedEvent.swift +++ b/Sources/StreamVideo/OpenApi/generated/Models/CallEndedEvent.swift @@ -3,6 +3,7 @@ // import Foundation +import StreamCore public final class CallEndedEvent: @unchecked Sendable, Event, Codable, JSONEncodable, Hashable, WSCallEvent { diff --git a/Sources/StreamVideo/OpenApi/generated/Models/CallFrameRecordingFailedEvent.swift b/Sources/StreamVideo/OpenApi/generated/Models/CallFrameRecordingFailedEvent.swift index 91ce5a6d4..b564ba462 100644 --- a/Sources/StreamVideo/OpenApi/generated/Models/CallFrameRecordingFailedEvent.swift +++ b/Sources/StreamVideo/OpenApi/generated/Models/CallFrameRecordingFailedEvent.swift @@ -3,6 +3,7 @@ // import Foundation +import StreamCore public final class CallFrameRecordingFailedEvent: @unchecked Sendable, Event, Codable, JSONEncodable, Hashable, WSCallEvent { public var call: CallResponse diff --git a/Sources/StreamVideo/OpenApi/generated/Models/CallFrameRecordingFrameReadyEvent.swift b/Sources/StreamVideo/OpenApi/generated/Models/CallFrameRecordingFrameReadyEvent.swift index 1464b7ac5..8330ed710 100644 --- a/Sources/StreamVideo/OpenApi/generated/Models/CallFrameRecordingFrameReadyEvent.swift +++ b/Sources/StreamVideo/OpenApi/generated/Models/CallFrameRecordingFrameReadyEvent.swift @@ -3,6 +3,7 @@ // import Foundation +import StreamCore public final class CallFrameRecordingFrameReadyEvent: @unchecked Sendable, Event, Codable, JSONEncodable, Hashable, WSCallEvent { public var callCid: String diff --git a/Sources/StreamVideo/OpenApi/generated/Models/CallFrameRecordingStartedEvent.swift b/Sources/StreamVideo/OpenApi/generated/Models/CallFrameRecordingStartedEvent.swift index 6dcb59826..cbd48041b 100644 --- a/Sources/StreamVideo/OpenApi/generated/Models/CallFrameRecordingStartedEvent.swift +++ b/Sources/StreamVideo/OpenApi/generated/Models/CallFrameRecordingStartedEvent.swift @@ -3,6 +3,7 @@ // import Foundation +import StreamCore public final class CallFrameRecordingStartedEvent: @unchecked Sendable, Event, Codable, JSONEncodable, Hashable, WSCallEvent { public var call: CallResponse diff --git a/Sources/StreamVideo/OpenApi/generated/Models/CallFrameRecordingStoppedEvent.swift b/Sources/StreamVideo/OpenApi/generated/Models/CallFrameRecordingStoppedEvent.swift index 09c5087b9..2763eedbc 100644 --- a/Sources/StreamVideo/OpenApi/generated/Models/CallFrameRecordingStoppedEvent.swift +++ b/Sources/StreamVideo/OpenApi/generated/Models/CallFrameRecordingStoppedEvent.swift @@ -3,6 +3,7 @@ // import Foundation +import StreamCore public final class CallFrameRecordingStoppedEvent: @unchecked Sendable, Event, Codable, JSONEncodable, Hashable, WSCallEvent { public var call: CallResponse diff --git a/Sources/StreamVideo/OpenApi/generated/Models/CallHLSBroadcastingFailedEvent.swift b/Sources/StreamVideo/OpenApi/generated/Models/CallHLSBroadcastingFailedEvent.swift index 66d02d942..d27c61d1d 100644 --- a/Sources/StreamVideo/OpenApi/generated/Models/CallHLSBroadcastingFailedEvent.swift +++ b/Sources/StreamVideo/OpenApi/generated/Models/CallHLSBroadcastingFailedEvent.swift @@ -3,6 +3,7 @@ // import Foundation +import StreamCore public final class CallHLSBroadcastingFailedEvent: @unchecked Sendable, Event, Codable, JSONEncodable, Hashable, WSCallEvent { diff --git a/Sources/StreamVideo/OpenApi/generated/Models/CallHLSBroadcastingStartedEvent.swift b/Sources/StreamVideo/OpenApi/generated/Models/CallHLSBroadcastingStartedEvent.swift index fbeff154d..75a33708a 100644 --- a/Sources/StreamVideo/OpenApi/generated/Models/CallHLSBroadcastingStartedEvent.swift +++ b/Sources/StreamVideo/OpenApi/generated/Models/CallHLSBroadcastingStartedEvent.swift @@ -3,6 +3,7 @@ // import Foundation +import StreamCore public final class CallHLSBroadcastingStartedEvent: @unchecked Sendable, Event, Codable, JSONEncodable, Hashable, WSCallEvent { diff --git a/Sources/StreamVideo/OpenApi/generated/Models/CallHLSBroadcastingStoppedEvent.swift b/Sources/StreamVideo/OpenApi/generated/Models/CallHLSBroadcastingStoppedEvent.swift index c1a895e8d..1a32c987d 100644 --- a/Sources/StreamVideo/OpenApi/generated/Models/CallHLSBroadcastingStoppedEvent.swift +++ b/Sources/StreamVideo/OpenApi/generated/Models/CallHLSBroadcastingStoppedEvent.swift @@ -3,6 +3,7 @@ // import Foundation +import StreamCore public final class CallHLSBroadcastingStoppedEvent: @unchecked Sendable, Event, Codable, JSONEncodable, Hashable, WSCallEvent { diff --git a/Sources/StreamVideo/OpenApi/generated/Models/CallLiveStartedEvent.swift b/Sources/StreamVideo/OpenApi/generated/Models/CallLiveStartedEvent.swift index ae777e6eb..3d518c69e 100644 --- a/Sources/StreamVideo/OpenApi/generated/Models/CallLiveStartedEvent.swift +++ b/Sources/StreamVideo/OpenApi/generated/Models/CallLiveStartedEvent.swift @@ -3,6 +3,7 @@ // import Foundation +import StreamCore public final class CallLiveStartedEvent: @unchecked Sendable, Event, Codable, JSONEncodable, Hashable, WSCallEvent { diff --git a/Sources/StreamVideo/OpenApi/generated/Models/CallMemberAddedEvent.swift b/Sources/StreamVideo/OpenApi/generated/Models/CallMemberAddedEvent.swift index f4e8ac539..310fbd3c1 100644 --- a/Sources/StreamVideo/OpenApi/generated/Models/CallMemberAddedEvent.swift +++ b/Sources/StreamVideo/OpenApi/generated/Models/CallMemberAddedEvent.swift @@ -3,6 +3,7 @@ // import Foundation +import StreamCore public final class CallMemberAddedEvent: @unchecked Sendable, Event, Codable, JSONEncodable, Hashable, WSCallEvent { diff --git a/Sources/StreamVideo/OpenApi/generated/Models/CallMemberRemovedEvent.swift b/Sources/StreamVideo/OpenApi/generated/Models/CallMemberRemovedEvent.swift index 80b8686fd..2b84bcc14 100644 --- a/Sources/StreamVideo/OpenApi/generated/Models/CallMemberRemovedEvent.swift +++ b/Sources/StreamVideo/OpenApi/generated/Models/CallMemberRemovedEvent.swift @@ -3,6 +3,7 @@ // import Foundation +import StreamCore public final class CallMemberRemovedEvent: @unchecked Sendable, Event, Codable, JSONEncodable, Hashable, WSCallEvent { diff --git a/Sources/StreamVideo/OpenApi/generated/Models/CallMemberUpdatedEvent.swift b/Sources/StreamVideo/OpenApi/generated/Models/CallMemberUpdatedEvent.swift index 5bd6455f3..71142079d 100644 --- a/Sources/StreamVideo/OpenApi/generated/Models/CallMemberUpdatedEvent.swift +++ b/Sources/StreamVideo/OpenApi/generated/Models/CallMemberUpdatedEvent.swift @@ -3,6 +3,7 @@ // import Foundation +import StreamCore public final class CallMemberUpdatedEvent: @unchecked Sendable, Event, Codable, JSONEncodable, Hashable, WSCallEvent { diff --git a/Sources/StreamVideo/OpenApi/generated/Models/CallMemberUpdatedPermissionEvent.swift b/Sources/StreamVideo/OpenApi/generated/Models/CallMemberUpdatedPermissionEvent.swift index 1ffc32899..8a4994d93 100644 --- a/Sources/StreamVideo/OpenApi/generated/Models/CallMemberUpdatedPermissionEvent.swift +++ b/Sources/StreamVideo/OpenApi/generated/Models/CallMemberUpdatedPermissionEvent.swift @@ -3,6 +3,7 @@ // import Foundation +import StreamCore public final class CallMemberUpdatedPermissionEvent: @unchecked Sendable, Event, Codable, JSONEncodable, Hashable, WSCallEvent { diff --git a/Sources/StreamVideo/OpenApi/generated/Models/CallMissedEvent.swift b/Sources/StreamVideo/OpenApi/generated/Models/CallMissedEvent.swift index ca74f910f..a254c4454 100644 --- a/Sources/StreamVideo/OpenApi/generated/Models/CallMissedEvent.swift +++ b/Sources/StreamVideo/OpenApi/generated/Models/CallMissedEvent.swift @@ -3,6 +3,7 @@ // import Foundation +import StreamCore public final class CallMissedEvent: @unchecked Sendable, Event, Codable, JSONEncodable, Hashable, WSCallEvent { diff --git a/Sources/StreamVideo/OpenApi/generated/Models/CallModerationBlurEvent.swift b/Sources/StreamVideo/OpenApi/generated/Models/CallModerationBlurEvent.swift index 23973e7a4..cb871dd6b 100644 --- a/Sources/StreamVideo/OpenApi/generated/Models/CallModerationBlurEvent.swift +++ b/Sources/StreamVideo/OpenApi/generated/Models/CallModerationBlurEvent.swift @@ -2,6 +2,7 @@ // Copyright © 2026 Stream.io Inc. All rights reserved. // +import StreamCore import Foundation public final class CallModerationBlurEvent: @unchecked Sendable, Event, Codable, JSONEncodable, Hashable, WSCallEvent { diff --git a/Sources/StreamVideo/OpenApi/generated/Models/CallModerationWarningEvent.swift b/Sources/StreamVideo/OpenApi/generated/Models/CallModerationWarningEvent.swift index b2820a91f..f9e70dfbc 100644 --- a/Sources/StreamVideo/OpenApi/generated/Models/CallModerationWarningEvent.swift +++ b/Sources/StreamVideo/OpenApi/generated/Models/CallModerationWarningEvent.swift @@ -2,6 +2,7 @@ // Copyright © 2026 Stream.io Inc. All rights reserved. // +import StreamCore import Foundation public final class CallModerationWarningEvent: @unchecked Sendable, Event, Codable, JSONEncodable, Hashable, WSCallEvent { diff --git a/Sources/StreamVideo/OpenApi/generated/Models/CallNotificationEvent.swift b/Sources/StreamVideo/OpenApi/generated/Models/CallNotificationEvent.swift index f2be09328..03cbd953a 100644 --- a/Sources/StreamVideo/OpenApi/generated/Models/CallNotificationEvent.swift +++ b/Sources/StreamVideo/OpenApi/generated/Models/CallNotificationEvent.swift @@ -3,6 +3,7 @@ // import Foundation +import StreamCore public final class CallNotificationEvent: @unchecked Sendable, Event, Codable, JSONEncodable, Hashable, WSCallEvent { diff --git a/Sources/StreamVideo/OpenApi/generated/Models/CallReactionEvent.swift b/Sources/StreamVideo/OpenApi/generated/Models/CallReactionEvent.swift index e69f42d0d..557d2aefe 100644 --- a/Sources/StreamVideo/OpenApi/generated/Models/CallReactionEvent.swift +++ b/Sources/StreamVideo/OpenApi/generated/Models/CallReactionEvent.swift @@ -3,6 +3,7 @@ // import Foundation +import StreamCore public final class CallReactionEvent: @unchecked Sendable, Event, Codable, JSONEncodable, Hashable, WSCallEvent { diff --git a/Sources/StreamVideo/OpenApi/generated/Models/CallRecordingFailedEvent.swift b/Sources/StreamVideo/OpenApi/generated/Models/CallRecordingFailedEvent.swift index accf715f2..bd4067e70 100644 --- a/Sources/StreamVideo/OpenApi/generated/Models/CallRecordingFailedEvent.swift +++ b/Sources/StreamVideo/OpenApi/generated/Models/CallRecordingFailedEvent.swift @@ -3,6 +3,7 @@ // import Foundation +import StreamCore public final class CallRecordingFailedEvent: @unchecked Sendable, Event, Codable, JSONEncodable, Hashable, WSCallEvent { diff --git a/Sources/StreamVideo/OpenApi/generated/Models/CallRecordingReadyEvent.swift b/Sources/StreamVideo/OpenApi/generated/Models/CallRecordingReadyEvent.swift index f1cb2ab42..645826404 100644 --- a/Sources/StreamVideo/OpenApi/generated/Models/CallRecordingReadyEvent.swift +++ b/Sources/StreamVideo/OpenApi/generated/Models/CallRecordingReadyEvent.swift @@ -3,6 +3,7 @@ // import Foundation +import StreamCore public final class CallRecordingReadyEvent: @unchecked Sendable, Event, Codable, JSONEncodable, Hashable, WSCallEvent { diff --git a/Sources/StreamVideo/OpenApi/generated/Models/CallRecordingStartedEvent.swift b/Sources/StreamVideo/OpenApi/generated/Models/CallRecordingStartedEvent.swift index 0c440a591..3465f7613 100644 --- a/Sources/StreamVideo/OpenApi/generated/Models/CallRecordingStartedEvent.swift +++ b/Sources/StreamVideo/OpenApi/generated/Models/CallRecordingStartedEvent.swift @@ -3,6 +3,7 @@ // import Foundation +import StreamCore public final class CallRecordingStartedEvent: @unchecked Sendable, Event, Codable, JSONEncodable, Hashable, WSCallEvent { diff --git a/Sources/StreamVideo/OpenApi/generated/Models/CallRecordingStoppedEvent.swift b/Sources/StreamVideo/OpenApi/generated/Models/CallRecordingStoppedEvent.swift index 32a95bda6..691a62ac7 100644 --- a/Sources/StreamVideo/OpenApi/generated/Models/CallRecordingStoppedEvent.swift +++ b/Sources/StreamVideo/OpenApi/generated/Models/CallRecordingStoppedEvent.swift @@ -3,6 +3,7 @@ // import Foundation +import StreamCore public final class CallRecordingStoppedEvent: @unchecked Sendable, Event, Codable, JSONEncodable, Hashable, WSCallEvent { diff --git a/Sources/StreamVideo/OpenApi/generated/Models/CallRejectedEvent.swift b/Sources/StreamVideo/OpenApi/generated/Models/CallRejectedEvent.swift index 9289df278..850a3b705 100644 --- a/Sources/StreamVideo/OpenApi/generated/Models/CallRejectedEvent.swift +++ b/Sources/StreamVideo/OpenApi/generated/Models/CallRejectedEvent.swift @@ -3,6 +3,7 @@ // import Foundation +import StreamCore public final class CallRejectedEvent: @unchecked Sendable, Event, Codable, JSONEncodable, Hashable, WSCallEvent { diff --git a/Sources/StreamVideo/OpenApi/generated/Models/CallRequest.swift b/Sources/StreamVideo/OpenApi/generated/Models/CallRequest.swift index 14629b87f..7ec0e5587 100644 --- a/Sources/StreamVideo/OpenApi/generated/Models/CallRequest.swift +++ b/Sources/StreamVideo/OpenApi/generated/Models/CallRequest.swift @@ -2,6 +2,7 @@ // Copyright © 2026 Stream.io Inc. All rights reserved. // +import StreamCore import Foundation public final class CallRequest: @unchecked Sendable, Codable, JSONEncodable, Hashable { diff --git a/Sources/StreamVideo/OpenApi/generated/Models/CallResponse.swift b/Sources/StreamVideo/OpenApi/generated/Models/CallResponse.swift index 4371db42b..6ada307df 100644 --- a/Sources/StreamVideo/OpenApi/generated/Models/CallResponse.swift +++ b/Sources/StreamVideo/OpenApi/generated/Models/CallResponse.swift @@ -2,6 +2,7 @@ // Copyright © 2026 Stream.io Inc. All rights reserved. // +import StreamCore import Foundation public final class CallResponse: @unchecked Sendable, Codable, JSONEncodable, Hashable { diff --git a/Sources/StreamVideo/OpenApi/generated/Models/CallRingEvent.swift b/Sources/StreamVideo/OpenApi/generated/Models/CallRingEvent.swift index 6f606d3ed..cfdb69a84 100644 --- a/Sources/StreamVideo/OpenApi/generated/Models/CallRingEvent.swift +++ b/Sources/StreamVideo/OpenApi/generated/Models/CallRingEvent.swift @@ -3,6 +3,7 @@ // import Foundation +import StreamCore public final class CallRingEvent: @unchecked Sendable, Event, Codable, JSONEncodable, Hashable, WSCallEvent { diff --git a/Sources/StreamVideo/OpenApi/generated/Models/CallRtmpBroadcastFailedEvent.swift b/Sources/StreamVideo/OpenApi/generated/Models/CallRtmpBroadcastFailedEvent.swift index e7171645b..35df7fc3e 100644 --- a/Sources/StreamVideo/OpenApi/generated/Models/CallRtmpBroadcastFailedEvent.swift +++ b/Sources/StreamVideo/OpenApi/generated/Models/CallRtmpBroadcastFailedEvent.swift @@ -3,6 +3,7 @@ // import Foundation +import StreamCore public final class CallRtmpBroadcastFailedEvent: @unchecked Sendable, Event, Codable, JSONEncodable, Hashable, WSCallEvent { diff --git a/Sources/StreamVideo/OpenApi/generated/Models/CallRtmpBroadcastStartedEvent.swift b/Sources/StreamVideo/OpenApi/generated/Models/CallRtmpBroadcastStartedEvent.swift index 081667865..df77a0982 100644 --- a/Sources/StreamVideo/OpenApi/generated/Models/CallRtmpBroadcastStartedEvent.swift +++ b/Sources/StreamVideo/OpenApi/generated/Models/CallRtmpBroadcastStartedEvent.swift @@ -3,6 +3,7 @@ // import Foundation +import StreamCore public final class CallRtmpBroadcastStartedEvent: @unchecked Sendable, Event, Codable, JSONEncodable, Hashable, WSCallEvent { diff --git a/Sources/StreamVideo/OpenApi/generated/Models/CallRtmpBroadcastStoppedEvent.swift b/Sources/StreamVideo/OpenApi/generated/Models/CallRtmpBroadcastStoppedEvent.swift index 9b4750eb6..58639de50 100644 --- a/Sources/StreamVideo/OpenApi/generated/Models/CallRtmpBroadcastStoppedEvent.swift +++ b/Sources/StreamVideo/OpenApi/generated/Models/CallRtmpBroadcastStoppedEvent.swift @@ -3,6 +3,7 @@ // import Foundation +import StreamCore public final class CallRtmpBroadcastStoppedEvent: @unchecked Sendable, Event, Codable, JSONEncodable, Hashable, WSCallEvent { diff --git a/Sources/StreamVideo/OpenApi/generated/Models/CallSessionEndedEvent.swift b/Sources/StreamVideo/OpenApi/generated/Models/CallSessionEndedEvent.swift index 51b71e99a..0eb3eeb07 100644 --- a/Sources/StreamVideo/OpenApi/generated/Models/CallSessionEndedEvent.swift +++ b/Sources/StreamVideo/OpenApi/generated/Models/CallSessionEndedEvent.swift @@ -3,6 +3,7 @@ // import Foundation +import StreamCore public final class CallSessionEndedEvent: @unchecked Sendable, Event, Codable, JSONEncodable, Hashable, WSCallEvent { diff --git a/Sources/StreamVideo/OpenApi/generated/Models/CallSessionParticipantCountsUpdatedEvent.swift b/Sources/StreamVideo/OpenApi/generated/Models/CallSessionParticipantCountsUpdatedEvent.swift index a46e5dfc3..a29e0f1ed 100644 --- a/Sources/StreamVideo/OpenApi/generated/Models/CallSessionParticipantCountsUpdatedEvent.swift +++ b/Sources/StreamVideo/OpenApi/generated/Models/CallSessionParticipantCountsUpdatedEvent.swift @@ -3,6 +3,7 @@ // import Foundation +import StreamCore public final class CallSessionParticipantCountsUpdatedEvent: @unchecked Sendable, Event, Codable, JSONEncodable, Hashable, WSCallEvent { diff --git a/Sources/StreamVideo/OpenApi/generated/Models/CallSessionParticipantJoinedEvent.swift b/Sources/StreamVideo/OpenApi/generated/Models/CallSessionParticipantJoinedEvent.swift index 2d462a431..9f7ea6653 100644 --- a/Sources/StreamVideo/OpenApi/generated/Models/CallSessionParticipantJoinedEvent.swift +++ b/Sources/StreamVideo/OpenApi/generated/Models/CallSessionParticipantJoinedEvent.swift @@ -3,6 +3,7 @@ // import Foundation +import StreamCore public final class CallSessionParticipantJoinedEvent: @unchecked Sendable, Event, Codable, JSONEncodable, Hashable, WSCallEvent { diff --git a/Sources/StreamVideo/OpenApi/generated/Models/CallSessionParticipantLeftEvent.swift b/Sources/StreamVideo/OpenApi/generated/Models/CallSessionParticipantLeftEvent.swift index c1612154a..839dfb5e3 100644 --- a/Sources/StreamVideo/OpenApi/generated/Models/CallSessionParticipantLeftEvent.swift +++ b/Sources/StreamVideo/OpenApi/generated/Models/CallSessionParticipantLeftEvent.swift @@ -3,6 +3,7 @@ // import Foundation +import StreamCore public final class CallSessionParticipantLeftEvent: @unchecked Sendable, Event, Codable, JSONEncodable, Hashable, WSCallEvent { diff --git a/Sources/StreamVideo/OpenApi/generated/Models/CallSessionStartedEvent.swift b/Sources/StreamVideo/OpenApi/generated/Models/CallSessionStartedEvent.swift index 8b3c2fb92..0fcf8ad31 100644 --- a/Sources/StreamVideo/OpenApi/generated/Models/CallSessionStartedEvent.swift +++ b/Sources/StreamVideo/OpenApi/generated/Models/CallSessionStartedEvent.swift @@ -3,6 +3,7 @@ // import Foundation +import StreamCore public final class CallSessionStartedEvent: @unchecked Sendable, Event, Codable, JSONEncodable, Hashable, WSCallEvent { diff --git a/Sources/StreamVideo/OpenApi/generated/Models/CallStatsReportReadyEvent.swift b/Sources/StreamVideo/OpenApi/generated/Models/CallStatsReportReadyEvent.swift index 54d0127e9..dff61a53e 100644 --- a/Sources/StreamVideo/OpenApi/generated/Models/CallStatsReportReadyEvent.swift +++ b/Sources/StreamVideo/OpenApi/generated/Models/CallStatsReportReadyEvent.swift @@ -3,6 +3,7 @@ // import Foundation +import StreamCore public final class CallStatsReportReadyEvent: @unchecked Sendable, Event, Codable, JSONEncodable, Hashable, WSCallEvent { public var callCid: String diff --git a/Sources/StreamVideo/OpenApi/generated/Models/CallTranscriptionFailedEvent.swift b/Sources/StreamVideo/OpenApi/generated/Models/CallTranscriptionFailedEvent.swift index d17ed3798..1cf5ba9ee 100644 --- a/Sources/StreamVideo/OpenApi/generated/Models/CallTranscriptionFailedEvent.swift +++ b/Sources/StreamVideo/OpenApi/generated/Models/CallTranscriptionFailedEvent.swift @@ -3,6 +3,7 @@ // import Foundation +import StreamCore public final class CallTranscriptionFailedEvent: @unchecked Sendable, Event, Codable, JSONEncodable, Hashable, WSCallEvent { diff --git a/Sources/StreamVideo/OpenApi/generated/Models/CallTranscriptionReadyEvent.swift b/Sources/StreamVideo/OpenApi/generated/Models/CallTranscriptionReadyEvent.swift index bee0a3e26..bf1b5627e 100644 --- a/Sources/StreamVideo/OpenApi/generated/Models/CallTranscriptionReadyEvent.swift +++ b/Sources/StreamVideo/OpenApi/generated/Models/CallTranscriptionReadyEvent.swift @@ -3,6 +3,7 @@ // import Foundation +import StreamCore public final class CallTranscriptionReadyEvent: @unchecked Sendable, Event, Codable, JSONEncodable, Hashable, WSCallEvent { diff --git a/Sources/StreamVideo/OpenApi/generated/Models/CallTranscriptionStartedEvent.swift b/Sources/StreamVideo/OpenApi/generated/Models/CallTranscriptionStartedEvent.swift index b8ecee29f..198be71e1 100644 --- a/Sources/StreamVideo/OpenApi/generated/Models/CallTranscriptionStartedEvent.swift +++ b/Sources/StreamVideo/OpenApi/generated/Models/CallTranscriptionStartedEvent.swift @@ -3,6 +3,7 @@ // import Foundation +import StreamCore public final class CallTranscriptionStartedEvent: @unchecked Sendable, Event, Codable, JSONEncodable, Hashable, WSCallEvent { diff --git a/Sources/StreamVideo/OpenApi/generated/Models/CallTranscriptionStoppedEvent.swift b/Sources/StreamVideo/OpenApi/generated/Models/CallTranscriptionStoppedEvent.swift index e9a20d3f4..4098dcfa6 100644 --- a/Sources/StreamVideo/OpenApi/generated/Models/CallTranscriptionStoppedEvent.swift +++ b/Sources/StreamVideo/OpenApi/generated/Models/CallTranscriptionStoppedEvent.swift @@ -3,6 +3,7 @@ // import Foundation +import StreamCore public final class CallTranscriptionStoppedEvent: @unchecked Sendable, Event, Codable, JSONEncodable, Hashable, WSCallEvent { diff --git a/Sources/StreamVideo/OpenApi/generated/Models/CallUpdatedEvent.swift b/Sources/StreamVideo/OpenApi/generated/Models/CallUpdatedEvent.swift index cc54d3c80..08603288d 100644 --- a/Sources/StreamVideo/OpenApi/generated/Models/CallUpdatedEvent.swift +++ b/Sources/StreamVideo/OpenApi/generated/Models/CallUpdatedEvent.swift @@ -3,6 +3,7 @@ // import Foundation +import StreamCore public final class CallUpdatedEvent: @unchecked Sendable, Event, Codable, JSONEncodable, Hashable, WSCallEvent { diff --git a/Sources/StreamVideo/OpenApi/generated/Models/CallUserFeedbackSubmittedEvent.swift b/Sources/StreamVideo/OpenApi/generated/Models/CallUserFeedbackSubmittedEvent.swift index b164186b5..8b3697e1e 100644 --- a/Sources/StreamVideo/OpenApi/generated/Models/CallUserFeedbackSubmittedEvent.swift +++ b/Sources/StreamVideo/OpenApi/generated/Models/CallUserFeedbackSubmittedEvent.swift @@ -2,6 +2,7 @@ // Copyright © 2026 Stream.io Inc. All rights reserved. // +import StreamCore import Foundation public final class CallUserFeedbackSubmittedEvent: @unchecked Sendable, Event, Codable, JSONEncodable, Hashable, WSCallEvent { diff --git a/Sources/StreamVideo/OpenApi/generated/Models/CallUserMutedEvent.swift b/Sources/StreamVideo/OpenApi/generated/Models/CallUserMutedEvent.swift index 2f180d076..afdc4023f 100644 --- a/Sources/StreamVideo/OpenApi/generated/Models/CallUserMutedEvent.swift +++ b/Sources/StreamVideo/OpenApi/generated/Models/CallUserMutedEvent.swift @@ -3,6 +3,7 @@ // import Foundation +import StreamCore public final class CallUserMutedEvent: @unchecked Sendable, Event, Codable, JSONEncodable, Hashable, WSCallEvent { diff --git a/Sources/StreamVideo/OpenApi/generated/Models/ClosedCaptionEvent.swift b/Sources/StreamVideo/OpenApi/generated/Models/ClosedCaptionEvent.swift index b086bf314..2b6115df8 100644 --- a/Sources/StreamVideo/OpenApi/generated/Models/ClosedCaptionEvent.swift +++ b/Sources/StreamVideo/OpenApi/generated/Models/ClosedCaptionEvent.swift @@ -3,6 +3,7 @@ // import Foundation +import StreamCore public final class ClosedCaptionEvent: @unchecked Sendable, Event, Codable, JSONEncodable, Hashable, WSCallEvent { diff --git a/Sources/StreamVideo/OpenApi/generated/Models/CollectUserFeedbackRequest.swift b/Sources/StreamVideo/OpenApi/generated/Models/CollectUserFeedbackRequest.swift index 68d7a5612..75dcb5034 100644 --- a/Sources/StreamVideo/OpenApi/generated/Models/CollectUserFeedbackRequest.swift +++ b/Sources/StreamVideo/OpenApi/generated/Models/CollectUserFeedbackRequest.swift @@ -2,6 +2,7 @@ // Copyright © 2026 Stream.io Inc. All rights reserved. // +import StreamCore import Foundation public final class CollectUserFeedbackRequest: @unchecked Sendable, Codable, JSONEncodable, Hashable { diff --git a/Sources/StreamVideo/OpenApi/generated/Models/ConnectUserDetailsRequest.swift b/Sources/StreamVideo/OpenApi/generated/Models/ConnectUserDetailsRequest.swift deleted file mode 100644 index 529f2c07b..000000000 --- a/Sources/StreamVideo/OpenApi/generated/Models/ConnectUserDetailsRequest.swift +++ /dev/null @@ -1,58 +0,0 @@ -// -// Copyright © 2026 Stream.io Inc. All rights reserved. -// - -import Foundation - -public final class ConnectUserDetailsRequest: @unchecked Sendable, Codable, JSONEncodable, Hashable { - - public var custom: [String: RawJSON]? - public var id: String - public var image: String? - public var invisible: Bool? - public var language: String? - public var name: String? - - public init( - custom: [String: RawJSON]? = nil, - id: String, - image: String? = nil, - invisible: Bool? = nil, - language: String? = nil, - name: String? = nil - ) { - self.custom = custom - self.id = id - self.image = image - self.invisible = invisible - self.language = language - self.name = name - } - - public enum CodingKeys: String, CodingKey, CaseIterable { - case custom - case id - case image - case invisible - case language - case name - } - - public static func == (lhs: ConnectUserDetailsRequest, rhs: ConnectUserDetailsRequest) -> Bool { - lhs.custom == rhs.custom && - lhs.id == rhs.id && - lhs.image == rhs.image && - lhs.invisible == rhs.invisible && - lhs.language == rhs.language && - lhs.name == rhs.name - } - - public func hash(into hasher: inout Hasher) { - hasher.combine(custom) - hasher.combine(id) - hasher.combine(image) - hasher.combine(invisible) - hasher.combine(language) - hasher.combine(name) - } -} diff --git a/Sources/StreamVideo/OpenApi/generated/Models/ConnectedEvent.swift b/Sources/StreamVideo/OpenApi/generated/Models/ConnectedEvent.swift index 8f3a1c00e..3e9845da8 100644 --- a/Sources/StreamVideo/OpenApi/generated/Models/ConnectedEvent.swift +++ b/Sources/StreamVideo/OpenApi/generated/Models/ConnectedEvent.swift @@ -3,6 +3,7 @@ // import Foundation +import StreamCore public final class ConnectedEvent: @unchecked Sendable, Event, Codable, JSONEncodable, Hashable { diff --git a/Sources/StreamVideo/OpenApi/generated/Models/ConnectionErrorEvent.swift b/Sources/StreamVideo/OpenApi/generated/Models/ConnectionErrorEvent.swift index df884adeb..85a96bcf0 100644 --- a/Sources/StreamVideo/OpenApi/generated/Models/ConnectionErrorEvent.swift +++ b/Sources/StreamVideo/OpenApi/generated/Models/ConnectionErrorEvent.swift @@ -3,6 +3,7 @@ // import Foundation +import StreamCore public final class ConnectionErrorEvent: @unchecked Sendable, Event, Codable, JSONEncodable, Hashable { diff --git a/Sources/StreamVideo/OpenApi/generated/Models/CreateDeviceRequest.swift b/Sources/StreamVideo/OpenApi/generated/Models/CreateDeviceRequest.swift deleted file mode 100644 index 56464eab2..000000000 --- a/Sources/StreamVideo/OpenApi/generated/Models/CreateDeviceRequest.swift +++ /dev/null @@ -1,59 +0,0 @@ -// -// Copyright © 2026 Stream.io Inc. All rights reserved. -// - -import Foundation - -public final class CreateDeviceRequest: @unchecked Sendable, Codable, JSONEncodable, Hashable { - - public enum PushProvider: String, Sendable, Codable, CaseIterable { - case apn - case firebase - case huawei - case xiaomi - case unknown = "_unknown" - - public init(from decoder: Decoder) throws { - let container = try decoder.singleValueContainer() - if let decodedValue = try? container.decode(String.self), - let value = Self(rawValue: decodedValue) { - self = value - } else { - self = .unknown - } - } - } - - public var id: String - public var pushProvider: PushProvider - public var pushProviderName: String? - public var voipToken: Bool? - - public init(id: String, pushProvider: PushProvider, pushProviderName: String? = nil, voipToken: Bool? = nil) { - self.id = id - self.pushProvider = pushProvider - self.pushProviderName = pushProviderName - self.voipToken = voipToken - } - - public enum CodingKeys: String, CodingKey, CaseIterable { - case id - case pushProvider = "push_provider" - case pushProviderName = "push_provider_name" - case voipToken = "voip_token" - } - - public static func == (lhs: CreateDeviceRequest, rhs: CreateDeviceRequest) -> Bool { - lhs.id == rhs.id && - lhs.pushProvider == rhs.pushProvider && - lhs.pushProviderName == rhs.pushProviderName && - lhs.voipToken == rhs.voipToken - } - - public func hash(into hasher: inout Hasher) { - hasher.combine(id) - hasher.combine(pushProvider) - hasher.combine(pushProviderName) - hasher.combine(voipToken) - } -} diff --git a/Sources/StreamVideo/OpenApi/generated/Models/CustomVideoEvent.swift b/Sources/StreamVideo/OpenApi/generated/Models/CustomVideoEvent.swift index d21b2ba7f..12f094393 100644 --- a/Sources/StreamVideo/OpenApi/generated/Models/CustomVideoEvent.swift +++ b/Sources/StreamVideo/OpenApi/generated/Models/CustomVideoEvent.swift @@ -2,6 +2,7 @@ // Copyright © 2026 Stream.io Inc. All rights reserved. // +import StreamCore import Foundation public final class CustomVideoEvent: @unchecked Sendable, Event, Codable, JSONEncodable, Hashable, WSCallEvent { diff --git a/Sources/StreamVideo/OpenApi/generated/Models/Device.swift b/Sources/StreamVideo/OpenApi/generated/Models/Device.swift deleted file mode 100644 index b887157a5..000000000 --- a/Sources/StreamVideo/OpenApi/generated/Models/Device.swift +++ /dev/null @@ -1,70 +0,0 @@ -// -// Copyright © 2026 Stream.io Inc. All rights reserved. -// - -import Foundation - -public final class Device: @unchecked Sendable, Codable, JSONEncodable, Hashable { - - public var createdAt: Date - public var disabled: Bool? - public var disabledReason: String? - public var id: String - public var pushProvider: String - public var pushProviderName: String? - public var userId: String - public var voip: Bool? - - public init( - createdAt: Date, - disabled: Bool? = nil, - disabledReason: String? = nil, - id: String, - pushProvider: String, - pushProviderName: String? = nil, - userId: String, - voip: Bool? = nil - ) { - self.createdAt = createdAt - self.disabled = disabled - self.disabledReason = disabledReason - self.id = id - self.pushProvider = pushProvider - self.pushProviderName = pushProviderName - self.userId = userId - self.voip = voip - } - - public enum CodingKeys: String, CodingKey, CaseIterable { - case createdAt = "created_at" - case disabled - case disabledReason = "disabled_reason" - case id - case pushProvider = "push_provider" - case pushProviderName = "push_provider_name" - case userId = "user_id" - case voip - } - - public static func == (lhs: Device, rhs: Device) -> Bool { - lhs.createdAt == rhs.createdAt && - lhs.disabled == rhs.disabled && - lhs.disabledReason == rhs.disabledReason && - lhs.id == rhs.id && - lhs.pushProvider == rhs.pushProvider && - lhs.pushProviderName == rhs.pushProviderName && - lhs.userId == rhs.userId && - lhs.voip == rhs.voip - } - - public func hash(into hasher: inout Hasher) { - hasher.combine(createdAt) - hasher.combine(disabled) - hasher.combine(disabledReason) - hasher.combine(id) - hasher.combine(pushProvider) - hasher.combine(pushProviderName) - hasher.combine(userId) - hasher.combine(voip) - } -} diff --git a/Sources/StreamVideo/OpenApi/generated/Models/HealthCheckEvent.swift b/Sources/StreamVideo/OpenApi/generated/Models/HealthCheckEvent.swift index 92b06191b..8898c8884 100644 --- a/Sources/StreamVideo/OpenApi/generated/Models/HealthCheckEvent.swift +++ b/Sources/StreamVideo/OpenApi/generated/Models/HealthCheckEvent.swift @@ -3,6 +3,7 @@ // import Foundation +import StreamCore public final class HealthCheckEvent: @unchecked Sendable, Event, Codable, JSONEncodable, Hashable { diff --git a/Sources/StreamVideo/OpenApi/generated/Models/KickedUserEvent.swift b/Sources/StreamVideo/OpenApi/generated/Models/KickedUserEvent.swift index cef10a32b..5d287d880 100644 --- a/Sources/StreamVideo/OpenApi/generated/Models/KickedUserEvent.swift +++ b/Sources/StreamVideo/OpenApi/generated/Models/KickedUserEvent.swift @@ -3,6 +3,7 @@ // import Foundation +import StreamCore public final class KickedUserEvent: @unchecked Sendable, Event, Codable, JSONEncodable, Hashable, WSCallEvent { public var callCid: String diff --git a/Sources/StreamVideo/OpenApi/generated/Models/LayoutSettings.swift b/Sources/StreamVideo/OpenApi/generated/Models/LayoutSettings.swift index e485710e6..09ef954f3 100644 --- a/Sources/StreamVideo/OpenApi/generated/Models/LayoutSettings.swift +++ b/Sources/StreamVideo/OpenApi/generated/Models/LayoutSettings.swift @@ -2,6 +2,7 @@ // Copyright © 2026 Stream.io Inc. All rights reserved. // +import StreamCore import Foundation public final class LayoutSettings: @unchecked Sendable, Codable, JSONEncodable, Hashable { diff --git a/Sources/StreamVideo/OpenApi/generated/Models/ListDevicesResponse.swift b/Sources/StreamVideo/OpenApi/generated/Models/ListDevicesResponse.swift deleted file mode 100644 index 530fc07b4..000000000 --- a/Sources/StreamVideo/OpenApi/generated/Models/ListDevicesResponse.swift +++ /dev/null @@ -1,31 +0,0 @@ -// -// Copyright © 2026 Stream.io Inc. All rights reserved. -// - -import Foundation - -public final class ListDevicesResponse: @unchecked Sendable, Codable, JSONEncodable, Hashable { - - public var devices: [Device] - public var duration: String - - public init(devices: [Device], duration: String) { - self.devices = devices - self.duration = duration - } - - public enum CodingKeys: String, CodingKey, CaseIterable { - case devices - case duration - } - - public static func == (lhs: ListDevicesResponse, rhs: ListDevicesResponse) -> Bool { - lhs.devices == rhs.devices && - lhs.duration == rhs.duration - } - - public func hash(into hasher: inout Hasher) { - hasher.combine(devices) - hasher.combine(duration) - } -} diff --git a/Sources/StreamVideo/OpenApi/generated/Models/MemberRequest.swift b/Sources/StreamVideo/OpenApi/generated/Models/MemberRequest.swift index d388b9d05..3c1dccf1c 100644 --- a/Sources/StreamVideo/OpenApi/generated/Models/MemberRequest.swift +++ b/Sources/StreamVideo/OpenApi/generated/Models/MemberRequest.swift @@ -2,6 +2,7 @@ // Copyright © 2026 Stream.io Inc. All rights reserved. // +import StreamCore import Foundation public final class MemberRequest: @unchecked Sendable, Codable, JSONEncodable, Hashable { diff --git a/Sources/StreamVideo/OpenApi/generated/Models/MemberResponse.swift b/Sources/StreamVideo/OpenApi/generated/Models/MemberResponse.swift index 68b728c9f..6fac27e17 100644 --- a/Sources/StreamVideo/OpenApi/generated/Models/MemberResponse.swift +++ b/Sources/StreamVideo/OpenApi/generated/Models/MemberResponse.swift @@ -2,6 +2,7 @@ // Copyright © 2026 Stream.io Inc. All rights reserved. // +import StreamCore import Foundation public final class MemberResponse: @unchecked Sendable, Codable, JSONEncodable, Hashable { diff --git a/Sources/StreamVideo/OpenApi/generated/Models/ModelResponse.swift b/Sources/StreamVideo/OpenApi/generated/Models/ModelResponse.swift deleted file mode 100644 index 9cef4abae..000000000 --- a/Sources/StreamVideo/OpenApi/generated/Models/ModelResponse.swift +++ /dev/null @@ -1,26 +0,0 @@ -// -// Copyright © 2026 Stream.io Inc. All rights reserved. -// - -import Foundation - -public final class ModelResponse: @unchecked Sendable, Codable, JSONEncodable, Hashable { - - public var duration: String - - public init(duration: String) { - self.duration = duration - } - - public enum CodingKeys: String, CodingKey, CaseIterable { - case duration - } - - public static func == (lhs: ModelResponse, rhs: ModelResponse) -> Bool { - lhs.duration == rhs.duration - } - - public func hash(into hasher: inout Hasher) { - hasher.combine(duration) - } -} diff --git a/Sources/StreamVideo/OpenApi/generated/Models/OwnUserResponse.swift b/Sources/StreamVideo/OpenApi/generated/Models/OwnUserResponse.swift index d1b0f0ef5..81b43ce70 100644 --- a/Sources/StreamVideo/OpenApi/generated/Models/OwnUserResponse.swift +++ b/Sources/StreamVideo/OpenApi/generated/Models/OwnUserResponse.swift @@ -2,6 +2,7 @@ // Copyright © 2026 Stream.io Inc. All rights reserved. // +import StreamCore import Foundation public final class OwnUserResponse: @unchecked Sendable, Codable, JSONEncodable, Hashable { diff --git a/Sources/StreamVideo/OpenApi/generated/Models/PermissionRequestEvent.swift b/Sources/StreamVideo/OpenApi/generated/Models/PermissionRequestEvent.swift index c7e01c3d8..194966f9e 100644 --- a/Sources/StreamVideo/OpenApi/generated/Models/PermissionRequestEvent.swift +++ b/Sources/StreamVideo/OpenApi/generated/Models/PermissionRequestEvent.swift @@ -3,6 +3,7 @@ // import Foundation +import StreamCore public final class PermissionRequestEvent: @unchecked Sendable, Event, Codable, JSONEncodable, Hashable, WSCallEvent { diff --git a/Sources/StreamVideo/OpenApi/generated/Models/QueryCallParticipantsRequest.swift b/Sources/StreamVideo/OpenApi/generated/Models/QueryCallParticipantsRequest.swift index 4c1a11f16..10b15b286 100644 --- a/Sources/StreamVideo/OpenApi/generated/Models/QueryCallParticipantsRequest.swift +++ b/Sources/StreamVideo/OpenApi/generated/Models/QueryCallParticipantsRequest.swift @@ -2,6 +2,7 @@ // Copyright © 2026 Stream.io Inc. All rights reserved. // +import StreamCore import Foundation public final class QueryCallParticipantsRequest: @unchecked Sendable, Codable, JSONEncodable, Hashable { diff --git a/Sources/StreamVideo/OpenApi/generated/Models/QueryCallStatsRequest.swift b/Sources/StreamVideo/OpenApi/generated/Models/QueryCallStatsRequest.swift index ecb4edd12..2e873c21f 100644 --- a/Sources/StreamVideo/OpenApi/generated/Models/QueryCallStatsRequest.swift +++ b/Sources/StreamVideo/OpenApi/generated/Models/QueryCallStatsRequest.swift @@ -2,6 +2,7 @@ // Copyright © 2026 Stream.io Inc. All rights reserved. // +import StreamCore import Foundation public final class QueryCallStatsRequest: @unchecked Sendable, Codable, JSONEncodable, Hashable { diff --git a/Sources/StreamVideo/OpenApi/generated/Models/QueryCallsRequest.swift b/Sources/StreamVideo/OpenApi/generated/Models/QueryCallsRequest.swift index 3b6ccbdf6..d70fad9e2 100644 --- a/Sources/StreamVideo/OpenApi/generated/Models/QueryCallsRequest.swift +++ b/Sources/StreamVideo/OpenApi/generated/Models/QueryCallsRequest.swift @@ -2,6 +2,7 @@ // Copyright © 2026 Stream.io Inc. All rights reserved. // +import StreamCore import Foundation public final class QueryCallsRequest: @unchecked Sendable, Codable, JSONEncodable, Hashable { diff --git a/Sources/StreamVideo/OpenApi/generated/Models/QueryMembersRequest.swift b/Sources/StreamVideo/OpenApi/generated/Models/QueryMembersRequest.swift index 9a8117cb6..01b5cc48f 100644 --- a/Sources/StreamVideo/OpenApi/generated/Models/QueryMembersRequest.swift +++ b/Sources/StreamVideo/OpenApi/generated/Models/QueryMembersRequest.swift @@ -2,6 +2,7 @@ // Copyright © 2026 Stream.io Inc. All rights reserved. // +import StreamCore import Foundation public final class QueryMembersRequest: @unchecked Sendable, Codable, JSONEncodable, Hashable { diff --git a/Sources/StreamVideo/OpenApi/generated/Models/ReactionResponse.swift b/Sources/StreamVideo/OpenApi/generated/Models/ReactionResponse.swift index 4682fe8ef..57b1b9649 100644 --- a/Sources/StreamVideo/OpenApi/generated/Models/ReactionResponse.swift +++ b/Sources/StreamVideo/OpenApi/generated/Models/ReactionResponse.swift @@ -2,6 +2,7 @@ // Copyright © 2026 Stream.io Inc. All rights reserved. // +import StreamCore import Foundation public final class ReactionResponse: @unchecked Sendable, Codable, JSONEncodable, Hashable { diff --git a/Sources/StreamVideo/OpenApi/generated/Models/SendEventRequest.swift b/Sources/StreamVideo/OpenApi/generated/Models/SendEventRequest.swift index 3148cda61..d8947b5a3 100644 --- a/Sources/StreamVideo/OpenApi/generated/Models/SendEventRequest.swift +++ b/Sources/StreamVideo/OpenApi/generated/Models/SendEventRequest.swift @@ -2,6 +2,7 @@ // Copyright © 2026 Stream.io Inc. All rights reserved. // +import StreamCore import Foundation public final class SendEventRequest: @unchecked Sendable, Codable, JSONEncodable, Hashable { diff --git a/Sources/StreamVideo/OpenApi/generated/Models/SendReactionRequest.swift b/Sources/StreamVideo/OpenApi/generated/Models/SendReactionRequest.swift index 9ed896546..a3c73fcbd 100644 --- a/Sources/StreamVideo/OpenApi/generated/Models/SendReactionRequest.swift +++ b/Sources/StreamVideo/OpenApi/generated/Models/SendReactionRequest.swift @@ -2,6 +2,7 @@ // Copyright © 2026 Stream.io Inc. All rights reserved. // +import StreamCore import Foundation public final class SendReactionRequest: @unchecked Sendable, Codable, JSONEncodable, Hashable { diff --git a/Sources/StreamVideo/OpenApi/generated/Models/UnblockedUserEvent.swift b/Sources/StreamVideo/OpenApi/generated/Models/UnblockedUserEvent.swift index a903d967c..14ed783ab 100644 --- a/Sources/StreamVideo/OpenApi/generated/Models/UnblockedUserEvent.swift +++ b/Sources/StreamVideo/OpenApi/generated/Models/UnblockedUserEvent.swift @@ -3,6 +3,7 @@ // import Foundation +import StreamCore public final class UnblockedUserEvent: @unchecked Sendable, Event, Codable, JSONEncodable, Hashable, WSCallEvent { diff --git a/Sources/StreamVideo/OpenApi/generated/Models/UpdateCallRequest.swift b/Sources/StreamVideo/OpenApi/generated/Models/UpdateCallRequest.swift index 3a9b10310..f338c138e 100644 --- a/Sources/StreamVideo/OpenApi/generated/Models/UpdateCallRequest.swift +++ b/Sources/StreamVideo/OpenApi/generated/Models/UpdateCallRequest.swift @@ -2,6 +2,7 @@ // Copyright © 2026 Stream.io Inc. All rights reserved. // +import StreamCore import Foundation public final class UpdateCallRequest: @unchecked Sendable, Codable, JSONEncodable, Hashable { diff --git a/Sources/StreamVideo/OpenApi/generated/Models/UpdatedCallPermissionsEvent.swift b/Sources/StreamVideo/OpenApi/generated/Models/UpdatedCallPermissionsEvent.swift index 6d7454bb5..591bbce04 100644 --- a/Sources/StreamVideo/OpenApi/generated/Models/UpdatedCallPermissionsEvent.swift +++ b/Sources/StreamVideo/OpenApi/generated/Models/UpdatedCallPermissionsEvent.swift @@ -3,6 +3,7 @@ // import Foundation +import StreamCore public final class UpdatedCallPermissionsEvent: @unchecked Sendable, Event, Codable, JSONEncodable, Hashable, WSCallEvent { diff --git a/Sources/StreamVideo/OpenApi/generated/Models/UserEventPayload.swift b/Sources/StreamVideo/OpenApi/generated/Models/UserEventPayload.swift index 781eaea50..0c78e953c 100644 --- a/Sources/StreamVideo/OpenApi/generated/Models/UserEventPayload.swift +++ b/Sources/StreamVideo/OpenApi/generated/Models/UserEventPayload.swift @@ -2,6 +2,7 @@ // Copyright © 2026 Stream.io Inc. All rights reserved. // +import StreamCore import Foundation public final class UserEventPayload: @unchecked Sendable, Codable, JSONEncodable, Hashable { diff --git a/Sources/StreamVideo/OpenApi/generated/Models/UserInfoResponse.swift b/Sources/StreamVideo/OpenApi/generated/Models/UserInfoResponse.swift index 685dc7b78..4a8e0543b 100644 --- a/Sources/StreamVideo/OpenApi/generated/Models/UserInfoResponse.swift +++ b/Sources/StreamVideo/OpenApi/generated/Models/UserInfoResponse.swift @@ -2,6 +2,7 @@ // Copyright © 2026 Stream.io Inc. All rights reserved. // +import StreamCore import Foundation public final class UserInfoResponse: @unchecked Sendable, Codable, JSONEncodable, Hashable { diff --git a/Sources/StreamVideo/OpenApi/generated/Models/UserRequest.swift b/Sources/StreamVideo/OpenApi/generated/Models/UserRequest.swift index da5617194..2b3e93677 100644 --- a/Sources/StreamVideo/OpenApi/generated/Models/UserRequest.swift +++ b/Sources/StreamVideo/OpenApi/generated/Models/UserRequest.swift @@ -2,6 +2,7 @@ // Copyright © 2026 Stream.io Inc. All rights reserved. // +import StreamCore import Foundation public final class UserRequest: @unchecked Sendable, Codable, JSONEncodable, Hashable { diff --git a/Sources/StreamVideo/OpenApi/generated/Models/UserResponse.swift b/Sources/StreamVideo/OpenApi/generated/Models/UserResponse.swift index 2a49caf9c..de7e4e91a 100644 --- a/Sources/StreamVideo/OpenApi/generated/Models/UserResponse.swift +++ b/Sources/StreamVideo/OpenApi/generated/Models/UserResponse.swift @@ -2,6 +2,7 @@ // Copyright © 2026 Stream.io Inc. All rights reserved. // +import StreamCore import Foundation public final class UserResponse: @unchecked Sendable, Codable, JSONEncodable, Hashable { diff --git a/Sources/StreamVideo/OpenApi/generated/Models/UserUpdatedEvent.swift b/Sources/StreamVideo/OpenApi/generated/Models/UserUpdatedEvent.swift index 67deb6a48..b6f27a623 100644 --- a/Sources/StreamVideo/OpenApi/generated/Models/UserUpdatedEvent.swift +++ b/Sources/StreamVideo/OpenApi/generated/Models/UserUpdatedEvent.swift @@ -3,6 +3,7 @@ // import Foundation +import StreamCore public final class UserUpdatedEvent: @unchecked Sendable, Event, Codable, JSONEncodable, Hashable { diff --git a/Sources/StreamVideo/OpenApi/generated/Models/VideoEvent.swift b/Sources/StreamVideo/OpenApi/generated/Models/VideoEvent.swift index 34e8f7beb..c3e5115ef 100644 --- a/Sources/StreamVideo/OpenApi/generated/Models/VideoEvent.swift +++ b/Sources/StreamVideo/OpenApi/generated/Models/VideoEvent.swift @@ -3,6 +3,7 @@ // import Foundation +import StreamCore internal protocol WSCallEvent { var callCid: String { get } diff --git a/Sources/StreamVideo/OpenApi/generated/Models/WSAuthMessageRequest.swift b/Sources/StreamVideo/OpenApi/generated/Models/WSAuthMessageRequest.swift deleted file mode 100644 index 0f08fe25c..000000000 --- a/Sources/StreamVideo/OpenApi/generated/Models/WSAuthMessageRequest.swift +++ /dev/null @@ -1,36 +0,0 @@ -// -// Copyright © 2026 Stream.io Inc. All rights reserved. -// - -import Foundation - -public final class WSAuthMessageRequest: @unchecked Sendable, Codable, JSONEncodable, Hashable { - - public var products: [String]? - public var token: String - public var userDetails: ConnectUserDetailsRequest - - public init(products: [String]? = nil, token: String, userDetails: ConnectUserDetailsRequest) { - self.products = products - self.token = token - self.userDetails = userDetails - } - - public enum CodingKeys: String, CodingKey, CaseIterable { - case products - case token - case userDetails = "user_details" - } - - public static func == (lhs: WSAuthMessageRequest, rhs: WSAuthMessageRequest) -> Bool { - lhs.products == rhs.products && - lhs.token == rhs.token && - lhs.userDetails == rhs.userDetails - } - - public func hash(into hasher: inout Hasher) { - hasher.combine(products) - hasher.combine(token) - hasher.combine(userDetails) - } -} diff --git a/Sources/StreamVideo/OpenApi/generated/OpenISO8601DateFormatter.swift b/Sources/StreamVideo/OpenApi/generated/OpenISO8601DateFormatter.swift deleted file mode 100644 index df29d177b..000000000 --- a/Sources/StreamVideo/OpenApi/generated/OpenISO8601DateFormatter.swift +++ /dev/null @@ -1,53 +0,0 @@ -// -// Copyright © 2026 Stream.io Inc. All rights reserved. -// - -import Foundation - -// https://stackoverflow.com/a/50281094/976628 -public class OpenISO8601DateFormatter: DateFormatter, @unchecked Sendable { - static let withoutSeconds: DateFormatter = { - let formatter = DateFormatter() - formatter.calendar = Calendar(identifier: .iso8601) - formatter.locale = Locale(identifier: "en_US_POSIX") - formatter.timeZone = TimeZone(secondsFromGMT: 0) - formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZZZZZ" - return formatter - }() - - static let withoutTime: DateFormatter = { - let formatter = DateFormatter() - formatter.calendar = Calendar(identifier: .iso8601) - formatter.locale = Locale(identifier: "en_US_POSIX") - formatter.timeZone = TimeZone(secondsFromGMT: 0) - formatter.dateFormat = "yyyy-MM-dd" - return formatter - }() - - private func setup() { - calendar = Calendar(identifier: .iso8601) - locale = Locale(identifier: "en_US_POSIX") - timeZone = TimeZone(secondsFromGMT: 0) - dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSZZZZZ" - } - - override init() { - super.init() - setup() - } - - required init?(coder aDecoder: NSCoder) { - super.init(coder: aDecoder) - setup() - } - - override public func date(from string: String) -> Date? { - if let result = super.date(from: string) { - return result - } else if let result = OpenISO8601DateFormatter.withoutSeconds.date(from: string) { - return result - } - - return OpenISO8601DateFormatter.withoutTime.date(from: string) - } -} diff --git a/Sources/StreamVideo/StreamVideo.swift b/Sources/StreamVideo/StreamVideo.swift index f7304e4e6..7479dc36e 100644 --- a/Sources/StreamVideo/StreamVideo.swift +++ b/Sources/StreamVideo/StreamVideo.swift @@ -4,12 +4,10 @@ import Combine import Foundation +@_exported import StreamCore import StreamWebRTC import SwiftProtobuf -public typealias UserTokenProvider = @Sendable (@Sendable @escaping (Result) -> Void) -> Void -public typealias UserTokenUpdater = @Sendable (UserToken) -> Void - /// Main class for interacting with the `StreamVideo` SDK. /// Needs to be initalized with a valid api key, user and token (and token provider). public class StreamVideo: ObservableObject, @unchecked Sendable { @@ -89,32 +87,25 @@ public class StreamVideo: ObservableObject, @unchecked Sendable { private let coordinatorClient: DefaultAPI private let apiTransport: DefaultAPITransport - private var webSocketClient: WebSocketClient? { - didSet { - setupConnectionRecoveryHandler() - } - } - + private var webSocketClient: CoordinatorWebSocket? + /// Observes coordinator connection-state changes on the main queue. + private var connectionStateCancellable: AnyCancellable? + private let eventsMiddleware = WSEventsMiddleware() private var cachedLocation: String? private var connectTask: Task? /// The notification center used to send and receive notifications about incoming events. - private(set) lazy var eventNotificationCenter: EventNotificationCenter = { - let center = EventNotificationCenter() + private(set) lazy var eventNotificationCenter: DefaultEventNotificationCenter = { + let center = DefaultEventNotificationCenter() eventsMiddleware.add(subscriber: self) - var middlewares: [EventMiddleware] = [ + let middlewares: [EventMiddleware] = [ eventsMiddleware ] center.add(middlewares: middlewares) return center }() - /// Background worker that takes care about client connection recovery when the Internet comes back - /// OR app transitions from background to foreground. - private(set) var connectionRecoveryHandler: ConnectionRecoveryHandler? - private(set) var timerType: Timer.Type = DefaultTimer.self - var tokenRetryTimer: TimerControl? var tokenExpirationRetryStrategy: RetryStrategy = DefaultRetryStrategy() @@ -349,15 +340,7 @@ public class StreamVideo: ObservableObject, @unchecked Sendable { /// Disconnects the current `StreamVideo` client. public func disconnect() async { - await withCheckedContinuation { [webSocketClient] continuation in - if let webSocketClient = webSocketClient { - webSocketClient.disconnect { - continuation.resume() - } - } else { - continuation.resume() - } - } + await webSocketClient?.disconnect() } /// Publishes all received video events coming from the coordinator. @@ -539,7 +522,7 @@ public class StreamVideo: ObservableObject, @unchecked Sendable { apiKey: apiKey.apiKeyString ) if let connectURL = try? URL(string: Self.endpointConfig.wsEndpoint)?.appendingQueryItems(queryParams) { - webSocketClient = makeWebSocketClient(url: connectURL, apiKey: apiKey) + webSocketClient = makeWebSocketClient(url: connectURL) webSocketClient?.connect() } else { throw ClientError.Unknown() @@ -559,33 +542,40 @@ public class StreamVideo: ObservableObject, @unchecked Sendable { } } - private func makeWebSocketClient( - url: URL, - apiKey: APIKey - ) -> WebSocketClient { - let webSocketClient = environment.webSocketClientBuilder(eventNotificationCenter, url) - - webSocketClient.connectionStateDelegate = self - webSocketClient.onWSConnectionEstablished = { [weak self, weak webSocketClient] in - guard let self = self, let webSocketClient else { return } - - let connectUserRequest = ConnectUserDetailsRequest( - custom: self.user.customData, - id: self.user.id, - image: self.user.imageURL?.absoluteString, - name: self.user.originalName - ) - - let authRequest = WSAuthMessageRequest( - token: self.token.rawValue, - userDetails: connectUserRequest - ) + private func makeWebSocketClient(url: URL) -> CoordinatorWebSocket { + let config = URLSessionConfiguration.default + config.waitsForConnectivity = false + let webSocketClient = CoordinatorWebSocket( + url: url, + eventNotificationCenter: eventNotificationCenter, + sessionConfiguration: config, + connectPayloadProvider: { [weak self] in self?.makeConnectPayload() }, + hasActiveCall: { InjectedValues[\.callKitService].callCount > 0 } + ) + + // The publisher fires on StreamCore's callback thread; hop to main since + // `handleConnectionStateChange` mutates the `@Published` `state.connection`. + connectionStateCancellable = webSocketClient + .connectionStatePublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] in self?.handleConnectionStateChange($0) } - webSocketClient.engine?.send(jsonMessage: authRequest) - } - return webSocketClient } + + /// Builds the coordinator connect payload (auth) sent once the socket opens. + private func makeConnectPayload() -> (any Codable)? { + let connectUserRequest = ConnectUserDetailsRequest( + custom: user.customData, + id: user.id, + image: user.imageURL?.absoluteString, + name: user.originalName + ) + return WSAuthMessageRequest( + token: token.rawValue, + userDetails: connectUserRequest + ) + } private func loadConnectionId() async -> String { if let connectionId = loadConnectionIdFromHealthcheck() { @@ -614,8 +604,8 @@ public class StreamVideo: ObservableObject, @unchecked Sendable { private func loadConnectionIdFromHealthcheck() -> String? { guard - case let .connected(healthCheckInfo: healtCheckInfo) = webSocketClient?.connectionState, - let connectionId = healtCheckInfo.coordinatorHealthCheck?.connectionId + case let .connected(healthCheckInfo: healthCheckInfo) = webSocketClient?.connectionState, + let connectionId = healthCheckInfo.connectionId else { return nil } @@ -682,18 +672,6 @@ public class StreamVideo: ObservableObject, @unchecked Sendable { return try await coordinatorClient.createDevice(createDeviceRequest: createDeviceRequest) } - private func setupConnectionRecoveryHandler() { - guard let webSocketClient = webSocketClient else { - return - } - - connectionRecoveryHandler = nil - connectionRecoveryHandler = environment.connectionRecoveryHandlerBuilder( - webSocketClient, - eventNotificationCenter - ) - } - private func connectUser(isInitial: Bool = false) async throws { if !isInitial && connectTask != nil { log.debug("Waiting for already running connect task") @@ -769,32 +747,30 @@ public class StreamVideo: ObservableObject, @unchecked Sendable { } } -extension StreamVideo: ConnectionStateDelegate { - - func webSocketClient( - _ client: WebSocketClient, - didUpdateConnectionState state: WebSocketConnectionState +extension StreamVideo { + + private func handleConnectionStateChange( + _ state: WebSocketConnectionState ) { - self.state.connection = ConnectionStatus(webSocketConnectionState: state) + self.state.connection = ConnectionStatus( + videoWebSocketConnectionState: state + ) switch state { case let .disconnected(source): - if let serverError = source.serverError { - if serverError.isInvalidTokenError { - Task(disposableBag: disposableBag) { [weak self] in - guard let self else { - return - } - do { - guard let apiTransport = apiTransport as? URLSessionTransport else { return } - self.tokenSubject.send(try await apiTransport.refreshToken()) - log.debug("user token updated, will reconnect ws") - webSocketClient?.connect() - } catch { - log.error("Error refreshing token, will disconnect ws connection", error: error) - } + if let serverError = source.serverError, + serverError.isInvalidTokenError || serverError.isTokenExpiredError { + Task(disposableBag: disposableBag) { [weak self] in + guard let self else { + return + } + do { + guard let apiTransport = apiTransport as? URLSessionTransport else { return } + self.tokenSubject.send(try await apiTransport.refreshToken()) + log.debug("user token updated, will reconnect ws") + webSocketClient?.connect() + } catch { + log.error("Error refreshing token, will disconnect ws connection", error: error) } - } else { - connectionRecoveryHandler?.webSocketClient(client, didUpdateConnectionState: state) } } eventSubject.send(.internalEvent(WSDisconnected())) diff --git a/Sources/StreamVideo/StreamVideoEnvironment.swift b/Sources/StreamVideo/StreamVideoEnvironment.swift index da9a85b25..3822fe221 100644 --- a/Sources/StreamVideo/StreamVideoEnvironment.swift +++ b/Sources/StreamVideo/StreamVideoEnvironment.swift @@ -6,25 +6,6 @@ import Foundation extension StreamVideo { struct Environment: Sendable { - var webSocketClientBuilder: @Sendable ( - _ eventNotificationCenter: EventNotificationCenter, - _ url: URL - ) -> WebSocketClient = { - let config = URLSessionConfiguration.default - config.waitsForConnectivity = false - - // Create a WebSocketClient. - let webSocketClient = WebSocketClient( - sessionConfiguration: config, - eventDecoder: JsonEventDecoder(), - eventNotificationCenter: $0, - webSocketClientType: .coordinator, - connectURL: $1 - ) - - return webSocketClient - } - var callControllerBuilder: @Sendable ( _ defaultAPI: DefaultAPI, _ user: User, @@ -56,35 +37,6 @@ extension StreamVideo { ) } - var connectionRecoveryHandlerBuilder: @Sendable ( - _ webSocketClient: WebSocketClient, - _ eventNotificationCenter: EventNotificationCenter - ) -> ConnectionRecoveryHandler = { - let backgroundTaskSchedulerBuilder: BackgroundTaskScheduler? = { - if Bundle.main.isAppExtension { - // No background task scheduler exists for app extensions. - return nil - } else { - #if os(iOS) - return IOSBackgroundTaskScheduler() - #else - // No need for background schedulers on macOS, app continues running when inactive. - return nil - #endif - } - }() - - return DefaultConnectionRecoveryHandler( - webSocketClient: $0, - eventNotificationCenter: $1, - backgroundTaskScheduler: backgroundTaskSchedulerBuilder, - internetConnection: InternetConnection(monitor: InternetConnection.Monitor()), - reconnectionStrategy: DefaultRetryStrategy(), - reconnectionTimerType: DefaultTimer.self, - keepConnectionAliveInBackground: true - ) - } - internal static func makeURLSession() -> URLSession { let config = URLSessionConfiguration.default config.requestCachePolicy = .reloadIgnoringLocalCacheData diff --git a/Sources/StreamVideo/Utils/Atomic.swift b/Sources/StreamVideo/Utils/Atomic.swift deleted file mode 100644 index f069aa17b..000000000 --- a/Sources/StreamVideo/Utils/Atomic.swift +++ /dev/null @@ -1,63 +0,0 @@ -// -// Copyright © 2026 Stream.io Inc. All rights reserved. -// - -import Foundation - -/// A mutable thread safe variable. -/// -/// - Warning: Be aware that accessing and setting a value are two distinct operations, so using operators like `+=` results -/// in two separate atomic operations. To work around this issue, you can access the wrapper directly and use the -/// `mutate(_ changes:)` method: -/// ``` -/// // Correct -/// atomicValue = 1 -/// let value = atomicValue -/// -/// atomicValue += 1 // Incorrect! Accessing and setting a value are two atomic operations. -/// _atomicValue.mutate { $0 += 1 } // Correct -/// _atomicValue { $0 += 1 } // Also possible -/// ``` -/// -/// - Note: Even though the value guarded by `Atomic` is thread-safe, the `Atomic` class itself is not. Mutating the instance -/// itself from multiple threads can cause a crash. - -@propertyWrapper -final class Atomic: @unchecked Sendable { - enum Mode { - case unfair - case recursive - - var queue: LockQueuing { - switch self { - case .unfair: - return UnfairQueue() - case .recursive: - return RecursiveQueue() - } - } - } - - private let queue: LockQueuing - private nonisolated(unsafe) var _value: T - - var wrappedValue: T { - get { queue.sync { _value } } - set { queue.sync { _value = newValue } } - } - - init(wrappedValue: T, mode: Mode = .unfair) { - _value = wrappedValue - queue = mode.queue - } - - /// Update the value safely. - /// - Parameter changes: a block with changes. It should return a new value. - func mutate(_ changes: (_ value: T) -> T) { - queue.sync { _value = changes(_value) } - } - - /// Update the value safely. - /// - Parameter changes: a block with changes. It should return a new value. - func callAsFunction(_ changes: (_ value: T) -> T) { mutate(changes) } -} diff --git a/Sources/StreamVideo/Utils/AudioSession/AudioRecorder/Namespace/Middleware/StreamCallAudioRecorder+AVAudioRecorderMiddleware.swift b/Sources/StreamVideo/Utils/AudioSession/AudioRecorder/Namespace/Middleware/StreamCallAudioRecorder+AVAudioRecorderMiddleware.swift index 7ef4ce86f..b065a5ac4 100644 --- a/Sources/StreamVideo/Utils/AudioSession/AudioRecorder/Namespace/Middleware/StreamCallAudioRecorder+AVAudioRecorderMiddleware.swift +++ b/Sources/StreamVideo/Utils/AudioSession/AudioRecorder/Namespace/Middleware/StreamCallAudioRecorder+AVAudioRecorderMiddleware.swift @@ -5,6 +5,7 @@ import AVFoundation import Combine import Foundation +import StreamCore extension StreamCallAudioRecorder.Namespace { /// Middleware that manages the `AVAudioRecorder` instance for audio diff --git a/Sources/StreamVideo/Utils/AudioSession/RTCAudioStore/Components/AVAudioSessionObserver.swift b/Sources/StreamVideo/Utils/AudioSession/RTCAudioStore/Components/AVAudioSessionObserver.swift index 1f6b8a59a..61336a882 100644 --- a/Sources/StreamVideo/Utils/AudioSession/RTCAudioStore/Components/AVAudioSessionObserver.swift +++ b/Sources/StreamVideo/Utils/AudioSession/RTCAudioStore/Components/AVAudioSessionObserver.swift @@ -5,6 +5,7 @@ import AVFoundation import Combine import Foundation +import StreamCore extension AVAudioSession { /// Captures a stable view of the session so state changes can be diffed diff --git a/Sources/StreamVideo/Utils/DisposableBag/DisposableBag.swift b/Sources/StreamVideo/Utils/DisposableBag/DisposableBag.swift deleted file mode 100644 index 2ed42a44b..000000000 --- a/Sources/StreamVideo/Utils/DisposableBag/DisposableBag.swift +++ /dev/null @@ -1,84 +0,0 @@ -// -// Copyright © 2026 Stream.io Inc. All rights reserved. -// - -// swiftlint:disable discourage_task_init - -import Combine -import Foundation - -public final class DisposableBag: @unchecked Sendable { - - private final class Storage { - private var storage: [String: AnyCancellable] = [:] - private let queue = UnfairQueue() - - deinit { - storage.values.forEach { $0.cancel() } - } - - func insert( - _ cancellable: AnyCancellable, - with key: String = UUID().uuidString - ) { - queue.sync { - storage[key]?.cancel() - storage[key] = cancellable - } - } - - func remove(_ key: String, cancel: Bool) { - queue.sync { - if cancel { - storage[key]?.cancel() - } - storage[key] = nil - } - } - - func removeAll() { - queue.sync { - storage.values.forEach { $0.cancel() } - storage = [:] - } - } - - var isEmpty: Bool { queue.sync { storage.isEmpty } } - } - - private let storage: Storage = .init() - - public init() {} - - public func insert( - _ cancellable: AnyCancellable, - with key: String = UUID().uuidString - ) { - storage.insert(cancellable, with: key) - } - - public func remove(_ key: String) { - storage.remove(key, cancel: true) - } - - public func removeAll() { - storage.removeAll() - } - - public var isEmpty: Bool { storage.isEmpty } - - public func completed(_ key: String) { - storage.remove(key, cancel: false) - } -} - -extension AnyCancellable { - public func store( - in disposableBag: DisposableBag?, - key: String = UUID().uuidString - ) { disposableBag?.insert(self, with: key) } -} - -extension Task { - func eraseToAnyCancellable() -> AnyCancellable { .init(cancel) } -} diff --git a/Sources/StreamVideo/Utils/Extensions/Concurrency/Task+DisposableBag.swift b/Sources/StreamVideo/Utils/Extensions/Concurrency/Task+DisposableBag.swift index afb535a76..53344dcc8 100644 --- a/Sources/StreamVideo/Utils/Extensions/Concurrency/Task+DisposableBag.swift +++ b/Sources/StreamVideo/Utils/Extensions/Concurrency/Task+DisposableBag.swift @@ -5,6 +5,7 @@ // swiftlint:disable discourage_task_init import Foundation +import StreamCore /// Extension to Task for integration with a `DisposableBag`. /// @@ -43,7 +44,7 @@ extension Task { line: line ) { await block() } } - store(in: disposableBag, identifier: identifier) + store(in: disposableBag, key: identifier) } /// Initializes and stores a throwing task in the given `DisposableBag`. @@ -76,7 +77,7 @@ extension Task { line: line ) { try await block() } } - store(in: disposableBag, identifier: identifier) + store(in: disposableBag, key: identifier) } /// Stores a cancel handler for the task in the specified `DisposableBag`. @@ -86,8 +87,8 @@ extension Task { /// - identifier: A unique key under which the cancel handler is stored. public func store( in disposableBag: DisposableBag, - identifier: String = UUID().uuidString + identifier: String ) { - disposableBag.insert(.init(cancel), with: identifier) + store(in: disposableBag, key: identifier) } } diff --git a/Sources/StreamVideo/Utils/Logger/Array+Logger.swift b/Sources/StreamVideo/Utils/Logger/Array+Logger.swift deleted file mode 100644 index 380f0f6eb..000000000 --- a/Sources/StreamVideo/Utils/Logger/Array+Logger.swift +++ /dev/null @@ -1,29 +0,0 @@ -// -// Copyright © 2026 Stream.io Inc. All rights reserved. -// - -import Foundation - -extension Array { - public func log( - _ level: LogLevel, - subsystems: LogSubsystem = .other, - functionName: StaticString = #function, - fileName: StaticString = #fileID, - lineNumber: UInt = #line, - messageBuilder: ((Self) -> String)? = nil - ) -> Self { - LogConfig - .logger - .log( - level, - functionName: functionName, - fileName: fileName, - lineNumber: lineNumber, - message: messageBuilder?(self) ?? "\(self)", - subsystems: subsystems, - error: nil - ) - return self - } -} diff --git a/Sources/StreamVideo/Utils/Logger/Destination/BaseLogDestination.swift b/Sources/StreamVideo/Utils/Logger/Destination/BaseLogDestination.swift deleted file mode 100644 index 26a6b64b2..000000000 --- a/Sources/StreamVideo/Utils/Logger/Destination/BaseLogDestination.swift +++ /dev/null @@ -1,133 +0,0 @@ -// -// Copyright © 2026 Stream.io Inc. All rights reserved. -// - -import Foundation - -/// Base class for log destinations. Already implements basic functionality to allow easy destination implementation. -/// Extending this class, instead of implementing `LogDestination` is easier (and recommended) for creating new destinations. -open class BaseLogDestination: LogDestination, @unchecked Sendable { - open var identifier: String - open var level: LogLevel - open var subsystems: LogSubsystem - - open var dateFormatter: DateFormatter - open var formatters: [LogFormatter] - - open var showDate: Bool - open var showLevel: Bool - open var showIdentifier: Bool - open var showThreadName: Bool - open var showFileName: Bool - open var showLineNumber: Bool - open var showFunctionName: Bool - - /// Initialize the log destination with given parameters. - /// - /// - Parameters: - /// - identifier: Identifier for this destination. Will be shown on the logs if `showIdentifier` is `true` - /// - level: Output level for this destination. Messages will only be shown if their output level is higher than this. - /// - showDate: Toggle for showing date in logs - /// - dateFormatter: DateFormatter instance for formatting the date in logs. Defaults to ISO8601 formatter. - /// - formatters: Log formatters to be applied in order before logs are outputted. Defaults to empty (no formatters). - /// Please see `LogFormatter` for more info. - /// - showLevel: Toggle for showing log level in logs - /// - showIdentifier: Toggle for showing identifier in logs - /// - showThreadName: Toggle for showing thread name in logs - /// - showFileName: Toggle for showing file name in logs - /// - showLineNumber: Toggle for showing line number in logs - /// - showFunctionName: Toggle for showing function name in logs - public required init( - identifier: String, - level: LogLevel, - subsystems: LogSubsystem, - showDate: Bool, - dateFormatter: DateFormatter, - formatters: [LogFormatter], - showLevel: Bool, - showIdentifier: Bool, - showThreadName: Bool, - showFileName: Bool, - showLineNumber: Bool, - showFunctionName: Bool - ) { - self.identifier = identifier - self.level = level - self.subsystems = subsystems - self.showIdentifier = showIdentifier - self.showThreadName = showThreadName - self.showDate = showDate - self.dateFormatter = dateFormatter - self.formatters = formatters - self.showLevel = showLevel - self.showFileName = showFileName - self.showLineNumber = showLineNumber - self.showFunctionName = showFunctionName - } - - open func isEnabled(level: LogLevel) -> Bool { - assertionFailure("`isEnabled(level:)` is deprecated, please use `isEnabled(level:subsystem:)`") - return true - } - - /// Checks if this destination is enabled for the given level and subsystems. - /// - Parameter level: Log level to be checked - /// - Parameter subsystems: Log subsystems to be checked - /// - Returns: `true` if destination is enabled for the given level, else `false` - open func isEnabled(level: LogLevel, subsystems: LogSubsystem) -> Bool { - level.rawValue >= self.level.rawValue && self.subsystems.contains(subsystems) - } - - /// Process the log details before outputting the log. - /// - Parameter logDetails: Log details to be processed. - open func process(logDetails: LogDetails) { - var extendedDetails: String = "" - - if showDate { - extendedDetails += "\(dateFormatter.string(from: logDetails.date)) " - } - - if showLevel { - extendedDetails += "[\(String(describing: logDetails.level).uppercased())] " - } - - if showIdentifier { - extendedDetails += "[\(logDetails.loggerIdentifier)-\(identifier)] " - } - - if showThreadName { - extendedDetails += logDetails.threadName - } - - if showFileName { - let fileName = (String(describing: logDetails.fileName) as NSString).lastPathComponent - extendedDetails += "[\(fileName)\(showLineNumber ? ":\(logDetails.lineNumber)" : "")] " - } else if showLineNumber { - extendedDetails += "[\(logDetails.lineNumber)] " - } - - if showFunctionName { - extendedDetails += "[\(logDetails.functionName)] " - } - - let extendedMessage = "\(extendedDetails)> \(logDetails.message)" - let formattedMessage = applyFormatters(logDetails: logDetails, message: extendedMessage) - write(message: formattedMessage) - } - - /// Apply formatters to the log message to be outputted - /// Be aware that formatters are order dependent. - /// - Parameters: - /// - logDetails: Log details to be passed on to formatters. - /// - message: Log message to be formatted - /// - Returns: Formatted log message, formatted by all formatters in order. - open func applyFormatters(logDetails: LogDetails, message: String) -> String { - formatters.reduce(message) { $1.format(logDetails: logDetails, message: $0) } - } - - /// Writes a given message to the desired output. - /// By minimum, subclasses should implement this function, since it handles outputting the message. - open func write(message: String) { - assertionFailure("Please extend this class and implement this function!") - } -} diff --git a/Sources/StreamVideo/Utils/Logger/Destination/ConsoleLogDestination.swift b/Sources/StreamVideo/Utils/Logger/Destination/ConsoleLogDestination.swift deleted file mode 100644 index 289ac9ccb..000000000 --- a/Sources/StreamVideo/Utils/Logger/Destination/ConsoleLogDestination.swift +++ /dev/null @@ -1,12 +0,0 @@ -// -// Copyright © 2026 Stream.io Inc. All rights reserved. -// - -import Foundation - -/// Basic destination for outputting messages to console. -public final class ConsoleLogDestination: BaseLogDestination, @unchecked Sendable { - override public func write(message: String) { - print(message) - } -} diff --git a/Sources/StreamVideo/Utils/Logger/Destination/LogDestination.swift b/Sources/StreamVideo/Utils/Logger/Destination/LogDestination.swift deleted file mode 100644 index 3624edb54..000000000 --- a/Sources/StreamVideo/Utils/Logger/Destination/LogDestination.swift +++ /dev/null @@ -1,78 +0,0 @@ -// -// Copyright © 2026 Stream.io Inc. All rights reserved. -// - -import Foundation - -/// Log level for any messages to be logged. -/// Please check [this Apple Logging Article](https://developer.apple.com/documentation/os/logging/generating_log_messages_from_your_code) to understand different logging levels. -public enum LogLevel: Int, Sendable { - /// Use this log level if you want to see everything that is logged. - case debug = 0 - /// Use this log level if you want to see what is happening during the app execution. - case info - /// Use this log level if you want to see if something is not 100% right. - case warning - /// Use this log level if you want to see only errors. - case error -} - -/// Encapsulates the components of a log message. -public struct LogDetails: Sendable { - public let loggerIdentifier: String - public let subsystem: LogSubsystem - - public let level: LogLevel - public let date: Date - public let message: String - public let threadName: String - - public let functionName: StaticString - public let fileName: StaticString - public let lineNumber: UInt - public let error: Error? -} - -public protocol LogDestination: Sendable { - var identifier: String { get set } - var level: LogLevel { get set } - var subsystems: LogSubsystem { get set } - - var dateFormatter: DateFormatter { get set } - var formatters: [LogFormatter] { get set } - - var showDate: Bool { get set } - var showLevel: Bool { get set } - var showIdentifier: Bool { get set } - var showThreadName: Bool { get set } - var showFileName: Bool { get set } - var showLineNumber: Bool { get set } - var showFunctionName: Bool { get set } - - init( - identifier: String, - level: LogLevel, - subsystems: LogSubsystem, - showDate: Bool, - dateFormatter: DateFormatter, - formatters: [LogFormatter], - showLevel: Bool, - showIdentifier: Bool, - showThreadName: Bool, - showFileName: Bool, - showLineNumber: Bool, - showFunctionName: Bool - ) - func isEnabled(level: LogLevel) -> Bool - func isEnabled(level: LogLevel, subsystems: LogSubsystem) -> Bool - func process(logDetails: LogDetails) - func applyFormatters(logDetails: LogDetails, message: String) -> String -} - -public extension LogDestination { - var subsystems: LogSubsystem { .all } - - func isEnabled(level: LogLevel, subsystems: LogSubsystem) -> Bool { - isEnabled(level: level) && self.subsystems.contains(subsystems) - } -} diff --git a/Sources/StreamVideo/Utils/Logger/Formatter/LogFormatter.swift b/Sources/StreamVideo/Utils/Logger/Formatter/LogFormatter.swift deleted file mode 100644 index 814644a73..000000000 --- a/Sources/StreamVideo/Utils/Logger/Formatter/LogFormatter.swift +++ /dev/null @@ -1,9 +0,0 @@ -// -// Copyright © 2026 Stream.io Inc. All rights reserved. -// - -import Foundation - -public protocol LogFormatter { - func format(logDetails: LogDetails, message: String) -> String -} diff --git a/Sources/StreamVideo/Utils/Logger/Formatter/PrefixLogFormatter.swift b/Sources/StreamVideo/Utils/Logger/Formatter/PrefixLogFormatter.swift deleted file mode 100644 index faa71baeb..000000000 --- a/Sources/StreamVideo/Utils/Logger/Formatter/PrefixLogFormatter.swift +++ /dev/null @@ -1,20 +0,0 @@ -// -// Copyright © 2026 Stream.io Inc. All rights reserved. -// - -import Foundation - -/// Formats the given log message with the given prefixes by log level. -/// Useful for emphasizing different leveled messages on console, when used as: -/// `prefixes: [.info: "ℹ️", .debug: "🛠", .error: "❌", .fault: "🚨"]` -public class PrefixLogFormatter: LogFormatter { - private let prefixes: [LogLevel: String] - - public init(prefixes: [LogLevel: String]) { - self.prefixes = prefixes - } - - public func format(logDetails: LogDetails, message: String) -> String { - prefixes[logDetails.level, default: ""] + " " + message - } -} diff --git a/Sources/StreamVideo/Utils/Logger/Logger+WebRTC.swift b/Sources/StreamVideo/Utils/Logger/Logger+WebRTC.swift index 71c0cee84..d93aca081 100644 --- a/Sources/StreamVideo/Utils/Logger/Logger+WebRTC.swift +++ b/Sources/StreamVideo/Utils/Logger/Logger+WebRTC.swift @@ -2,9 +2,19 @@ // Copyright © 2026 Stream.io Inc. All rights reserved. // +import Combine import Foundation +import StreamCore import StreamWebRTC +extension LogConfig { + /// Toggles internal WebRTC logging on or off. + public static var webRTCLogsEnabled: Bool { + get { Logger.WebRTC.mode != .none } + set { Logger.WebRTC.mode = newValue ? .all : .none } + } +} + extension Logger { public enum WebRTC { @@ -40,6 +50,8 @@ extension RTCLoggingSeverity { self = .warning case .error: self = .error + @unknown default: + self = .verbose } } } @@ -51,8 +63,12 @@ extension Logger.WebRTC { private let logger = RTCCallbackLogger() private var isRunning = false private let processingQueue = OperationQueue(maxConcurrentOperationCount: 1) + private let levelCancellable: AnyCancellable private init() { + levelCancellable = LogConfig.levelPublisher + .dropFirst() + .sink { severity = .init($0) } didUpdate(mode: mode) } @@ -92,18 +108,18 @@ extension Logger.WebRTC { switch severity { case .none, .verbose: if isMessageFromValidFile(trimmedMessage) { - log.debug(trimmedMessage, subsystems: .webRTCInternal) + log.debug(trimmedMessage, subsystems: .webRTC) } case .info: if isMessageFromValidFile(trimmedMessage) { - log.info(trimmedMessage, subsystems: .webRTCInternal) + log.info(trimmedMessage, subsystems: .webRTC) } case .warning: - log.warning(trimmedMessage, subsystems: .webRTCInternal) + log.warning(trimmedMessage, subsystems: .webRTC) case .error: - log.error(trimmedMessage, subsystems: .webRTCInternal) + log.error(trimmedMessage, subsystems: .webRTC) @unknown default: - log.debug(trimmedMessage, subsystems: .webRTCInternal) + log.debug(trimmedMessage, subsystems: .webRTC) } } diff --git a/Sources/StreamVideo/Utils/Logger/Logger.swift b/Sources/StreamVideo/Utils/Logger/Logger.swift deleted file mode 100644 index 1d9697ecd..000000000 --- a/Sources/StreamVideo/Utils/Logger/Logger.swift +++ /dev/null @@ -1,620 +0,0 @@ -// -// Copyright © 2026 Stream.io Inc. All rights reserved. -// - -import Combine -import Foundation - -public var log: Logger { - LogConfig.logger -} - -/// Entity for identifying which subsystem the log message comes from. -public struct LogSubsystem: OptionSet, CustomStringConvertible, Sendable { - public let rawValue: Int - - public init(rawValue: Int) { - self.rawValue = rawValue - } - - public static let allCases: [LogSubsystem] = [ - .database, - .httpRequests, - .webSocket, - .webRTC, - .other, - .offlineSupport, - .peerConnectionPublisher, - .peerConnectionSubscriber, - .sfu, - .iceAdapter, - .mediaAdapter, - .thermalState, - .audioSession, - .videoCapturer, - .pictureInPicture, - .callKit, - .webRTCInternal, - .audioRecording - ] - - /// All subsystems within the SDK. - public static let all: LogSubsystem = [ - .database, - .httpRequests, - .webSocket, - .webRTC, - .other, - .offlineSupport, - .peerConnectionPublisher, - .peerConnectionSubscriber, - .sfu, - .iceAdapter, - .mediaAdapter, - .thermalState, - .audioSession, - .videoCapturer, - .pictureInPicture, - .callKit, - .webRTCInternal, - .audioRecording - ] - - /// The subsystem responsible for any other part of the SDK. - /// This is the default subsystem value for logging, to be used when `subsystem` is not specified. - public static let other = Self(rawValue: 1 << 0) - - /// The subsystem responsible for database operations. - public static let database = Self(rawValue: 1 << 1) - /// The subsystem responsible for HTTP operations. - public static let httpRequests = Self(rawValue: 1 << 2) - /// The subsystem responsible for websocket operations. - public static let webSocket = Self(rawValue: 1 << 3) - /// The subsystem responsible for offline support. - public static let offlineSupport = Self(rawValue: 1 << 4) - /// The subsystem responsible for WebRTC. - public static let webRTC = Self(rawValue: 1 << 5) - /// The subsystem responsible for PeerConnections. - public static let peerConnectionPublisher = Self(rawValue: 1 << 6) - public static let peerConnectionSubscriber = Self(rawValue: 1 << 7) - /// The subsystem responsible for SFU interaction. - public static let sfu = Self(rawValue: 1 << 8) - /// The subsystem responsible for ICE interactions. - public static let iceAdapter = Self(rawValue: 1 << 9) - /// The subsystem responsible for Media publishing/subscribing. - public static let mediaAdapter = Self(rawValue: 1 << 10) - /// The subsystem responsible for ThermalState observation. - public static let thermalState = Self(rawValue: 1 << 11) - /// The subsystem responsible for interacting with the AudioSession. - public static let audioSession = Self(rawValue: 1 << 12) - /// The subsystem responsible for VideoCapturing components. - public static let videoCapturer = Self(rawValue: 1 << 13) - /// The subsystem responsible for PicutreInPicture. - public static let pictureInPicture = Self(rawValue: 1 << 14) - /// The subsystem responsible for PicutreInPicture. - public static let callKit = Self(rawValue: 1 << 15) - public static let webRTCInternal = Self(rawValue: 1 << 16) - public static let audioRecording = Self(rawValue: 1 << 17) - - public var description: String { - switch rawValue { - case LogSubsystem.other.rawValue: - return "other" - case LogSubsystem.database.rawValue: - return "database" - case LogSubsystem.httpRequests.rawValue: - return "httpRequests" - case LogSubsystem.webSocket.rawValue: - return "webSocket" - case LogSubsystem.offlineSupport.rawValue: - return "offlineSupport" - case LogSubsystem.webRTC.rawValue: - return "webRTC" - case LogSubsystem.peerConnectionPublisher.rawValue: - return "peerConnection-publisher" - case LogSubsystem.peerConnectionSubscriber.rawValue: - return "peerConnection-subscriber" - case LogSubsystem.sfu.rawValue: - return "sfu" - case LogSubsystem.iceAdapter.rawValue: - return "iceAdapter" - case LogSubsystem.mediaAdapter.rawValue: - return "mediaAdapter" - case LogSubsystem.thermalState.rawValue: - return "thermalState" - case LogSubsystem.audioSession.rawValue: - return "audioSession" - case LogSubsystem.videoCapturer.rawValue: - return "videoCapturer" - case LogSubsystem.pictureInPicture.rawValue: - return "picture-in-picture" - case LogSubsystem.callKit.rawValue: - return "CallKit" - case LogSubsystem.webRTCInternal.rawValue: - return "webRTC-Internal" - case LogSubsystem.audioRecording.rawValue: - return "audioRecording" - default: - return "unknown(rawValue:\(rawValue)" - } - } -} - -public enum LogConfig { - /// Identifier for the logger. Defaults to empty. - public nonisolated(unsafe) static var identifier = "" { - didSet { - invalidateLogger() - } - } - - /// Output level for the logger. - public nonisolated(unsafe) static var level: LogLevel = .error { - didSet { - invalidateLogger() - Logger.WebRTC.severity = .init(level) - } - } - - /// Date formatter for the logger. Defaults to ISO8601 - public nonisolated(unsafe) static var dateFormatter: DateFormatter = { - let df = DateFormatter() - df.dateFormat = "yyyy-MM-dd HH:mm:ss.SSS" - return df - }() { - didSet { - invalidateLogger() - } - } - - /// Log formatters to be applied in order before logs are outputted. Defaults to empty (no formatters). - /// Please see `LogFormatter` for more info. - public nonisolated(unsafe) static var formatters = [LogFormatter]() { - didSet { - invalidateLogger() - } - } - - /// Toggle for showing date in logs - public nonisolated(unsafe) static var showDate = true { - didSet { - invalidateLogger() - } - } - - /// Toggle for showing log level in logs - public nonisolated(unsafe) static var showLevel = true { - didSet { - invalidateLogger() - } - } - - /// Toggle for showing identifier in logs - public nonisolated(unsafe) static var showIdentifier = false { - didSet { - invalidateLogger() - } - } - - /// Toggle for showing thread name in logs - public nonisolated(unsafe) static var showThreadName = true { - didSet { - invalidateLogger() - } - } - - /// Toggle for showing file name in logs - public nonisolated(unsafe) static var showFileName = true { - didSet { - invalidateLogger() - } - } - - /// Toggle for showing line number in logs - public nonisolated(unsafe) static var showLineNumber = true { - didSet { - invalidateLogger() - } - } - - /// Toggle for showing function name in logs - public nonisolated(unsafe) static var showFunctionName = true { - didSet { - invalidateLogger() - } - } - - /// Subsystems for the logger - public nonisolated(unsafe) static var subsystems: LogSubsystem = .all { - didSet { - invalidateLogger() - } - } - - /// Destination types this logger will use. - /// - /// Logger will initialize the destinations with its own parameters. If you want full control on the parameters, use `destinations` directly, - /// where you can pass parameters to destination initializers yourself. - public nonisolated(unsafe) static var destinationTypes: [LogDestination.Type] = [ConsoleLogDestination.self] { - didSet { - invalidateLogger() - } - } - - private static let _destinations: AtomicStorage<[LogDestination]?> = .init(nil) - - /// Destinations for the default logger. Please see `LogDestination`. - /// Defaults to only `ConsoleLogDestination`, which only prints the messages. - /// - /// - Important: Other options in `ChatClientConfig.Logging` will not take affect if this is changed. - public static var destinations: [LogDestination] { - get { - if let destinations = _destinations.get() { - return destinations - } else { - let _destinationTypes = destinationTypes - let newDestinations = _destinationTypes.map { - $0.init( - identifier: identifier, - level: level, - subsystems: subsystems, - showDate: showDate, - dateFormatter: dateFormatter, - formatters: formatters, - showLevel: showLevel, - showIdentifier: showIdentifier, - showThreadName: showThreadName, - showFileName: showFileName, - showLineNumber: showLineNumber, - showFunctionName: showFunctionName - ) - } - _destinations.set(newDestinations) - return newDestinations - } - } - set { - invalidateLogger() - _destinations.set(newValue) - } - } - - /// Underlying logger instance to control singleton. - private nonisolated(unsafe) static var _logger: Logger? - - /// Logger instance to be used by StreamChat. - /// - /// - Important: Other options in `LogConfig` will not take affect if this is changed. - public static var logger: Logger { - get { - if let logger = _logger { - return logger - } else { - _logger = Logger(identifier: identifier, destinations: destinations) - return _logger! - } - } - set { - _logger = newValue - } - } - - public static var webRTCLogsEnabled: Bool { - get { Logger.WebRTC.mode != .none } - set { Logger.WebRTC.mode = newValue ? .all : .none } - } - - /// Invalidates the current logger instance so it can be recreated. - private static func invalidateLogger() { - _logger = nil - _destinations.set(nil) - } -} - -/// Entity used for logging messages. -public class Logger: @unchecked Sendable { - /// Identifier of the Logger. Will be visible if a destination has `showIdentifiers` enabled. - public let identifier: String - - /// Destinations for this logger. - /// See `LogDestination` protocol for details. - private var _destinations: CurrentValueSubject<[LogDestination], Never> - public var destinations: [LogDestination] { - get { _destinations.value } - set { _destinations.send(newValue) } - } - - private let loggerQueue = DispatchQueue(label: "LoggerQueue \(UUID())") - - /// Init a logger with a given identifier and destinations. - public init(identifier: String = "", destinations: [LogDestination] = []) { - self.identifier = identifier - _destinations = .init(destinations) - } - - /// Allows logger to be called as function. - /// Transforms, given that `let log = Logger()`, `log.log(.info, "Hello")` to `log(.info, "Hello")` for ease of use. - /// - /// - Parameters: - /// - level: Log level for this message - /// - functionName: Function of the caller - /// - fileName: File of the caller - /// - lineNumber: Line number of the caller - /// - message: Message to be logged - public func callAsFunction( - _ level: LogLevel, - functionName: StaticString = #function, - fileName: StaticString = #fileID, - lineNumber: UInt = #line, - message: @autoclosure () -> Any, - subsystems: LogSubsystem = .other, - error: Error? - ) { - log( - level, - functionName: functionName, - fileName: fileName, - lineNumber: lineNumber, - message: message(), - subsystems: subsystems, - error: error - ) - } - - /// Log a message to all enabled destinations. - /// See `Logger.destinations` for customizing the output. - /// - /// - Parameters: - /// - level: Log level for this message - /// - functionName: Function of the caller - /// - fileName: File of the caller - /// - lineNumber: Line number of the caller - /// - message: Message to be logged - public func log( - _ level: LogLevel, - functionName: StaticString = #function, - fileName: StaticString = #fileID, - lineNumber: UInt = #line, - message: @autoclosure () -> Any, - subsystems: LogSubsystem = .other, - error: Error? - ) { - let resolvedMessage = String(describing: message()) - let resolvedError = error - let resolvedThreadName = threadName - let timestamp = Date() - let loggerIdentifier = identifier - let destinations = self.destinations - - loggerQueue.async { - let enabledDestinations = destinations.filter { - $0.isEnabled(level: level, subsystems: subsystems) - } - guard !enabledDestinations.isEmpty else { return } - - let logDetails = LogDetails( - loggerIdentifier: loggerIdentifier, - subsystem: subsystems, - level: level, - date: timestamp, - message: resolvedMessage, - threadName: resolvedThreadName, - functionName: functionName, - fileName: fileName, - lineNumber: lineNumber, - error: resolvedError - ) - - for destination in enabledDestinations { - destination.process(logDetails: logDetails) - } - } - } - - /// Log an info message. - /// - /// - Parameters: - /// - message: Message to be logged - /// - functionName: Function of the caller - /// - fileName: File of the caller - /// - lineNumber: Line number of the caller - public func info( - _ message: @autoclosure () -> Any, - subsystems: LogSubsystem = .other, - functionName: StaticString = #function, - fileName: StaticString = #fileID, - lineNumber: UInt = #line - ) { - log( - .info, - functionName: functionName, - fileName: fileName, - lineNumber: lineNumber, - message: message(), - subsystems: subsystems, - error: nil - ) - } - - /// Log a debug message. - /// - /// - Parameters: - /// - message: Message to be logged - /// - functionName: Function of the caller - /// - fileName: File of the caller - /// - lineNumber: Line number of the caller - public func debug( - _ message: @autoclosure () -> Any, - subsystems: LogSubsystem = .other, - functionName: StaticString = #function, - fileName: StaticString = #fileID, - lineNumber: UInt = #line - ) { - log( - .debug, - functionName: functionName, - fileName: fileName, - lineNumber: lineNumber, - message: message(), - subsystems: subsystems, - error: nil - ) - } - - /// Log a warning message. - /// - /// - Parameters: - /// - message: Message to be logged - /// - functionName: Function of the caller - /// - fileName: File of the caller - /// - lineNumber: Line number of the caller - public func warning( - _ message: @autoclosure () -> Any, - subsystems: LogSubsystem = .other, - functionName: StaticString = #function, - fileName: StaticString = #fileID, - lineNumber: UInt = #line - ) { - log( - .warning, - functionName: functionName, - fileName: fileName, - lineNumber: lineNumber, - message: message(), - subsystems: subsystems, - error: nil - ) - } - - /// Log an error message. - /// - /// - Parameters: - /// - message: Message to be logged - /// - functionName: Function of the caller - /// - fileName: File of the caller - /// - lineNumber: Line number of the caller - public func error( - _ message: @autoclosure () -> Any, - subsystems: LogSubsystem = .other, - error: Error? = nil, - functionName: StaticString = #function, - fileName: StaticString = #fileID, - lineNumber: UInt = #line - ) { - // If the error isn't conforming to ``ReflectiveStringConvertible`` we - // wrap it in a ``ClientError`` to provide consistent logging information. - let error = { - guard let error, (error as? ReflectiveStringConvertible) == nil else { - return error - } - return ClientError(with: error, fileName, lineNumber) - }() - - log( - .error, - functionName: functionName, - fileName: fileName, - lineNumber: lineNumber, - message: message(), - subsystems: subsystems, - error: error - ) - } - - /// Performs `Swift.assert` and stops program execution if `condition` evaluated to false. In RELEASE builds only - /// logs the failure. - /// - /// - Parameters: - /// - condition: The condition to test. - /// - message: A custom message to log if `condition` is evaluated to false. - public func assert( - _ condition: @autoclosure () -> Bool, - _ message: @autoclosure () -> Any, - subsystems: LogSubsystem = .other, - functionName: StaticString = #function, - fileName: StaticString = #fileID, - lineNumber: UInt = #line - ) { - guard !condition() else { return } - if StreamRuntimeCheck.assertionsEnabled { - Swift.assert(condition(), String(describing: message()), file: fileName, line: lineNumber) - } - log( - .error, - functionName: functionName, - fileName: fileName, - lineNumber: lineNumber, - message: "Assert failed: \(message())", - subsystems: subsystems, - error: nil - ) - } - - /// Stops program execution with `Swift.assertionFailure`. In RELEASE builds only - /// logs the failure. - /// - /// - Parameters: - /// - message: A custom message to log if `condition` is evaluated to false. - public func assertionFailure( - _ message: @autoclosure () -> Any, - subsystems: LogSubsystem = .other, - functionName: StaticString = #function, - fileName: StaticString = #fileID, - lineNumber: UInt = #line - ) { - if StreamRuntimeCheck.assertionsEnabled { - Swift.assertionFailure(String(describing: message()), file: fileName, line: lineNumber) - } - log( - .error, - functionName: functionName, - fileName: fileName, - lineNumber: lineNumber, - message: "Assert failed: \(message())", - subsystems: subsystems, - error: nil - ) - } -} - -private extension Logger { - var threadName: String { - if Thread.isMainThread { - return "[main] " - } else { - if let threadName = Thread.current.name, !threadName.isEmpty { - return "[\(threadName)] " - } else if - let queueName = String(validatingUTF8: __dispatch_queue_get_label(nil)), !queueName.isEmpty { - return "[\(queueName)] " - } else { - return String(format: "[%p] ", Thread.current) - } - } - } -} - -extension Data { - /// Converts the data into a pretty-printed JSON string. Use only for debug purposes since this operation can be expensive. - var debugPrettyPrintedJSON: String { - do { - let jsonObject = try JSONSerialization.jsonObject(with: self, options: []) - let prettyPrintedData = try JSONSerialization.data(withJSONObject: jsonObject, options: [.prettyPrinted]) - return String(data: prettyPrintedData, encoding: .utf8) ?? "Error: Data to String decoding failed." - } catch { - return "" - } - } -} - -final class AtomicStorage: @unchecked Sendable { - - private let queue = UnfairQueue() - private var value: Element - - init(_ initial: Element) { value = initial } - - func get() -> Element { queue.sync { value } } - - func set(_ newValue: Element) { queue.sync { value = newValue } } -} diff --git a/Sources/StreamVideo/Utils/Logger/Publisher+Logger.swift b/Sources/StreamVideo/Utils/Logger/Publisher+Logger.swift deleted file mode 100644 index 39cdc59ef..000000000 --- a/Sources/StreamVideo/Utils/Logger/Publisher+Logger.swift +++ /dev/null @@ -1,149 +0,0 @@ -// -// Copyright © 2026 Stream.io Inc. All rights reserved. -// - -import Combine - -extension Publishers { - /// A Publisher that logs values emitted by an upstream publisher. - public struct Log: Publisher { - public typealias Output = Upstream.Output - public typealias Failure = Upstream.Failure - - private let upstream: Upstream - private let level: LogLevel - private let subsystems: LogSubsystem - private let functionName: StaticString - private let fileName: StaticString - private let lineNumber: UInt - private let messageBuilder: ((Self.Output) -> String)? - - /// Initializes a new Log publisher. - /// - /// - Parameters: - /// - upstream: The upstream publisher to log values from. - /// - level: The log level to use. - /// - subsystems: The subsystem(s) to associate with the log messages. - /// - functionName: The name of the function where logging occurs. - /// - fileName: The name of the file where logging occurs. - /// - lineNumber: The line number where logging occurs. - /// - messageBuilder: An optional closure to customize the log message. - init( - upstream: Upstream, - level: LogLevel, - subsystems: LogSubsystem, - functionName: StaticString, - fileName: StaticString, - lineNumber: UInt, - messageBuilder: ((Self.Output) -> String)? - ) { - self.upstream = upstream - self.level = level - self.subsystems = subsystems - self.functionName = functionName - self.fileName = fileName - self.lineNumber = lineNumber - self.messageBuilder = messageBuilder - } - - public func receive( - subscriber: S - ) where S: Subscriber, Failure == S.Failure, Output == S.Input { - upstream.receive( - subscriber: LogSubscriber( - downstream: subscriber, - level: level, - subsystems: subsystems, - functionName: functionName, - fileName: fileName, - lineNumber: lineNumber, - messageBuilder: messageBuilder - ) - ) - } - } -} - -/// A custom Subscriber that logs values before passing them downstream. -private final class LogSubscriber: Subscriber { - typealias Input = Downstream.Input - typealias Failure = Downstream.Failure - - private let downstream: Downstream - private let level: LogLevel - private let subsystems: LogSubsystem - private let functionName: StaticString - private let fileName: StaticString - private let lineNumber: UInt - private let messageBuilder: ((Input) -> String)? - - init( - downstream: Downstream, - level: LogLevel, - subsystems: LogSubsystem, - functionName: StaticString, - fileName: StaticString, - lineNumber: UInt, - messageBuilder: ((Input) -> String)? - ) { - self.downstream = downstream - self.level = level - self.subsystems = subsystems - self.functionName = functionName - self.fileName = fileName - self.lineNumber = lineNumber - self.messageBuilder = messageBuilder - } - - func receive(subscription: Subscription) { - downstream.receive(subscription: subscription) - } - - func receive(_ input: Input) -> Subscribers.Demand { - log.log( - level, - functionName: functionName, - fileName: fileName, - lineNumber: lineNumber, - message: messageBuilder?(input) ?? "\(input)", - subsystems: subsystems, - error: nil - ) - return downstream.receive(input) - } - - func receive(completion: Subscribers.Completion) { - downstream.receive(completion: completion) - } -} - -extension Publisher { - /// Logs the publisher's input using Stream Logger - /// - /// - Parameters: - /// - level: The log level to use. - /// - subsystems: The subsystem(s) to associate with the log messages. - /// - functionName: The name of the function where logging occurs. - /// - fileName: The name of the file where logging occurs. - /// - lineNumber: The line number where logging occurs. - /// - messageBuilder: An optional closure to customize the log message. - /// - Returns: A publisher that logs values before passing them downstream. - public func log( - _ level: LogLevel, - subsystems: LogSubsystem = .other, - functionName: StaticString = #function, - fileName: StaticString = #fileID, - lineNumber: UInt = #line, - messageBuilder: ((Self.Output) -> String)? = nil - ) -> Publishers.Log { - Publishers.Log( - upstream: self, - level: level, - subsystems: subsystems, - functionName: functionName, - fileName: fileName, - lineNumber: lineNumber, - messageBuilder: messageBuilder - ) - } -} diff --git a/Sources/StreamVideo/Utils/Logger/Signposting.swift b/Sources/StreamVideo/Utils/Logger/Signposting.swift index f0d24369c..519e62659 100644 --- a/Sources/StreamVideo/Utils/Logger/Signposting.swift +++ b/Sources/StreamVideo/Utils/Logger/Signposting.swift @@ -4,6 +4,7 @@ import Foundation import OSLog +import StreamCore public protocol Signposting { /// Measures the execution time of a synchronous block using signposts. diff --git a/Sources/StreamVideo/Utils/OrderedCapacityQueue/OrderedCapacityQueue.swift b/Sources/StreamVideo/Utils/OrderedCapacityQueue/OrderedCapacityQueue.swift index 1fc097894..2e946b771 100644 --- a/Sources/StreamVideo/Utils/OrderedCapacityQueue/OrderedCapacityQueue.swift +++ b/Sources/StreamVideo/Utils/OrderedCapacityQueue/OrderedCapacityQueue.swift @@ -4,6 +4,7 @@ import Combine import Foundation +import StreamCore /// A thread-safe queue that maintains a fixed capacity and removes elements after /// a specified time interval. diff --git a/Sources/StreamVideo/Utils/Queues/LockQueuing.swift b/Sources/StreamVideo/Utils/Queues/LockQueuing.swift deleted file mode 100644 index 651e8c903..000000000 --- a/Sources/StreamVideo/Utils/Queues/LockQueuing.swift +++ /dev/null @@ -1,22 +0,0 @@ -// -// Copyright © 2026 Stream.io Inc. All rights reserved. -// - -import Foundation - -/// A protocol defining a lock-based synchronization interface. -/// -/// Types conforming to `LockQueuing` provide thread-safe access to resources -/// by executing blocks of code within a lock, ensuring mutual exclusion. -protocol LockQueuing: Sendable { - - /// Executes a block within a lock, ensuring exclusive access. - /// - /// This method should guarantee that only one thread can execute the - /// provided block at a time, using the underlying lock mechanism. - /// - /// - Parameter block: The block of code to execute safely within the lock. - /// - Returns: The value returned by the block, if any. - /// - Throws: Rethrows any errors thrown by the block. - func sync(_ block: () throws -> T) rethrows -> T -} diff --git a/Sources/StreamVideo/Utils/Queues/RecursiveQueue.swift b/Sources/StreamVideo/Utils/Queues/RecursiveQueue.swift deleted file mode 100644 index 04d509134..000000000 --- a/Sources/StreamVideo/Utils/Queues/RecursiveQueue.swift +++ /dev/null @@ -1,40 +0,0 @@ -// -// Copyright © 2026 Stream.io Inc. All rights reserved. -// - -import Foundation - -/// A synchronization utility implementing a recursive locking mechanism. -/// -/// This class provides a way to synchronize access to resources using `NSRecursiveLock`, -/// which allows the same thread to acquire the lock multiple times without deadlocking. -/// This is useful in recursive or reentrant code where multiple methods that may call each other -/// need to access a shared resource. -/// -/// `NSRecursiveLock` is an Objective-C-based recursive lock that provides thread safety in situations -/// where the same thread needs to acquire the lock more than once. Unlike `os_unfair_lock`, which is non-recursive -/// and prioritizes performance, `NSRecursiveLock` ensures that the same thread can enter critical sections -/// repeatedly, avoiding deadlock in reentrant code scenarios. -/// -/// While `NSRecursiveLock` has more overhead than `os_unfair_lock`, it is ideal for more complex code flows -/// where reentrancy or recursive method calls require safe locking. This makes it a better fit than -/// `os_unfair_lock` when you need the flexibility to enter the lock multiple times on the same thread. -public final class RecursiveQueue: LockQueuing, @unchecked Sendable { - - /// The recursive lock instance. - private let lock = NSRecursiveLock() - - /// Initializes a new instance of `RecursiveQueue`. - public init() {} - - /// Executes a block with mutual exclusion via a recursive lock. - /// - /// - Parameter block: The block of code to execute safely under the lock. - /// - Returns: The value returned by the block, if any. - /// - Throws: Rethrows any errors thrown by the block. - public func sync(_ block: () throws -> T) rethrows -> T { - lock.lock() - defer { lock.unlock() } - return try block() - } -} diff --git a/Sources/StreamVideo/Utils/Queues/UnfairQueue.swift b/Sources/StreamVideo/Utils/Queues/UnfairQueue.swift deleted file mode 100644 index a0b69306c..000000000 --- a/Sources/StreamVideo/Utils/Queues/UnfairQueue.swift +++ /dev/null @@ -1,58 +0,0 @@ -// -// Copyright © 2026 Stream.io Inc. All rights reserved. -// - -import Foundation - -/// A synchronization utility implementing an unfair locking mechanism. -/// -/// This class provides a way to synchronize access to resources using `os_unfair_lock`, -/// which offers a fast and efficient locking mechanism but can lead to priority inversion. -/// -/// `os_unfair_lock` stands out for its direct approach to synchronization, providing high-performance, -/// low-level locking that is especially beneficial when you need to protect small sections of code or data -/// swiftly. It operates with minimal overhead, avoiding the complexities of context switching or thread management, -/// which is a stark contrast to DispatchQueue. -/// -/// When using a DispatchQueue, there is inherent overhead because it schedules tasks and potentially shifts -/// execution onto a specific thread, depending on the queue's configuration (main or background). This can -/// introduce delays as tasks are queued and executed according to the system's scheduling algorithms, -/// which might not be as immediate as the direct locking mechanism provided by `os_unfair_lock`. -/// -/// The lock's efficiency and speed are particularly advantageous in high-performance contexts where the -/// additional overhead of dispatching tasks and managing thread execution in a DispatchQueue could result -/// in unnecessary latency, making `os_unfair_lock` the superior choice for scenarios where rapid, lightweight -/// synchronization is paramount. -public final class UnfairQueue: LockQueuing, @unchecked Sendable { - - /// The unfair lock variable, managed as an unsafe mutable pointer to `os_unfair_lock`. - private let lock: os_unfair_lock_t - - /// Initializes a new instance of `UnfairQueue`. - /// - /// It allocates memory for an `os_unfair_lock` and initializes it. - public init() { - lock = UnsafeMutablePointer.allocate(capacity: 1) - lock.initialize(to: os_unfair_lock()) - } - - /// Deinitializes the instance, deallocating the unfair lock. - deinit { - lock.deinitialize(count: 1) - lock.deallocate() - } - - /// Executes a block of code, ensuring mutual exclusion via an unfair lock. - /// - /// The method locks before the block executes and unlocks after the block completes. - /// It's designed to be exception-safe, unlocking even if an error is thrown within the block. - /// - /// - Parameter block: The block of code to execute safely under the lock. - /// - Returns: The value returned by the block, if any. - /// - Throws: Rethrows any errors that are thrown by the block. - public func sync(_ block: () throws -> T) rethrows -> T { - os_unfair_lock_lock(lock) - defer { os_unfair_lock_unlock(lock) } - return try block() - } -} diff --git a/Sources/StreamVideo/Utils/RawJSON+StreamCore.swift b/Sources/StreamVideo/Utils/RawJSON+StreamCore.swift new file mode 100644 index 000000000..e1ef5a34e --- /dev/null +++ b/Sources/StreamVideo/Utils/RawJSON+StreamCore.swift @@ -0,0 +1,72 @@ +// +// Copyright © 2026 Stream.io Inc. All rights reserved. +// + +import Foundation +import StreamCore + +public extension RawJSON { + /// Extracts the wrapped value as the specified type, if possible. + func value() -> T? { + switch self { + case let .number(double): + if T.self == Int.self { return Int(double) as? T } + if T.self == Int32.self { return Int32(double) as? T } + if T.self == Int64.self { return Int64(double) as? T } + if T.self == UInt.self { return UInt(double) as? T } + if T.self == UInt32.self { return UInt32(double) as? T } + if T.self == UInt64.self { return UInt64(double) as? T } + if T.self == Double.self { return double as? T } + if T.self == Float.self { return Float(double) as? T } + return double as? T + case let .string(string): + return string as? T + case let .bool(bool): + return bool as? T + case let .dictionary(dictionary): + return dictionary as? T + case let .array(array): + return array as? T + case .nil: + return nil + @unknown default: + return nil + } + } + + /// Extracts the wrapped value as the specified type, or returns a fallback. + func value(fallback: T) -> T { + value() ?? fallback + } +} + +extension RawJSON { + init(_ object: NSObject) { + switch object { + case let string as NSString: + self = .string(string as String) + case let number as NSNumber: + self = .number(number.doubleValue) + case let array as NSArray: + self = .array( + array.compactMap { + guard let object = $0 as? NSObject else { return nil } + return .init(object) + } + ) + case let dictionary as NSDictionary: + self = .dictionary( + dictionary.reduce(into: [:]) { result, element in + guard let key = element.key as? String, + let value = element.value as? NSObject + else { + return + } + result[key] = .init(value) + } + ) + default: + self = .string(object.description) + } + } +} diff --git a/Sources/StreamVideo/Utils/RawJSON.swift b/Sources/StreamVideo/Utils/RawJSON.swift deleted file mode 100644 index 09e3a7134..000000000 --- a/Sources/StreamVideo/Utils/RawJSON.swift +++ /dev/null @@ -1,431 +0,0 @@ -// -// Copyright © 2026 Stream.io Inc. All rights reserved. -// - -import Foundation - -/// A `RawJSON` type. -/// Used to store and operate objects of unknown structure that's not possible to decode. -/// https://forums.swift.org/t/new-unevaluated-type-for-decoder-to-allow-later-re-encoding-of-data-with-unknown-structure/11117 -public indirect enum RawJSON: Codable, Hashable, Sendable { - case number(Double) - case string(String) - case bool(Bool) - case dictionary([String: RawJSON]) - case array([RawJSON]) - case `nil` - - public init(from decoder: Decoder) throws { - let singleValueContainer = try decoder.singleValueContainer() - if let value = try? singleValueContainer.decode(Bool.self) { - self = .bool(value) - return - } else if let value = try? singleValueContainer.decode(String.self) { - self = .string(value) - return - } else if let value = try? singleValueContainer.decode(Double.self) { - self = .number(value) - return - } else if let value = try? singleValueContainer.decode([String: RawJSON].self) { - self = .dictionary(value) - return - } else if let value = try? singleValueContainer.decode([RawJSON].self) { - self = .array(value) - return - } else if singleValueContainer.decodeNil() { - self = .nil - return - } - throw DecodingError - .dataCorrupted( - DecodingError - .Context(codingPath: decoder.codingPath, debugDescription: "Could not find reasonable type to map to JSONValue") - ) - } - - public func encode(to encoder: Encoder) throws { - var container = encoder.singleValueContainer() - switch self { - case let .number(value): try container.encode(value) - case let .bool(value): try container.encode(value) - case let .string(value): try container.encode(value) - case let .array(value): try container.encode(value) - case let .dictionary(value): try container.encode(value) - case .nil: try container.encodeNil() - } - } -} - -// MARK: Raw Values Helpers - -public extension RawJSON { - /// Extracts a number value of RawJSON. - /// Returns nil if the value is not a number. - /// - /// Example: - /// ``` - /// let customData = message.customData - /// let price = customData["price"]?.numberValue ?? 0 - /// ``` - var numberValue: Double? { - guard case let .number(value) = self else { - return nil - } - return value - } - - /// Extracts a string value of RawJSON. - /// Returns nil if the value is not a string. - /// - /// Example: - /// ``` - /// let customData = message.customData - /// let email = customData["email"]?.stringValue ?? "" - /// ``` - var stringValue: String? { - guard case let .string(value) = self else { - return nil - } - return value - } - - /// Extracts a bool value of RawJSON. - /// Returns nil if the value is not a bool. - /// - /// Example: - /// ``` - /// let customData = message.customData - /// let isManager = customData["isManager"]?.boolValue ?? false - /// ``` - var boolValue: Bool? { - guard case let .bool(value) = self else { - return nil - } - return value - } - - /// Extracts a dictionary value of RawJSON. - /// Returns nil if the value is not a dictionary. - /// - /// Example: - /// ``` - /// let customData = message.customData - /// let flightPrice = customData["flight"]?.dictionaryValue?["price"]?.numberValue ?? 0 - /// ``` - var dictionaryValue: [String: RawJSON]? { - guard case let .dictionary(value) = self else { - return nil - } - return value - } - - /// Extracts an array value of RawJSON. - /// Returns nil if the value is not an array. - /// - /// Example: - /// ``` - /// let customData = message.customData - /// let flights: [RawJSON]? = customData["flights"]?.arrayValue - /// ``` - var arrayValue: [RawJSON]? { - guard case let .array(value) = self else { - return nil - } - return value - } - - /// Extracts a number array of RawJSON. - /// Returns nil if the value is not an array of numbers. - /// - /// Example: - /// ``` - /// let customData = message.customData - /// let ages = customData["ages"]?.numberArrayValue ?? [] - /// ``` - var numberArrayValue: [Double]? { - guard let rawArrayValue = arrayValue else { - return nil - } - - return rawArrayValue.compactMap(\.numberValue) - } - - /// Extracts a string array of RawJSON. - /// Returns nil if the value is not an array of strings. - /// - /// Example: - /// ``` - /// let customData = message.customData - /// let names = customData["names"]?.stringArrayValue ?? [] - /// ``` - var stringArrayValue: [String]? { - guard let rawArrayValue = arrayValue else { - return nil - } - - return rawArrayValue.compactMap(\.stringValue) - } - - /// Extracts a bool array of RawJSON. - /// Returns nil if the value is not an array of bools. - var boolArrayValue: [Bool]? { - guard let rawArrayValue = arrayValue else { - return nil - } - - return rawArrayValue.compactMap(\.boolValue) - } - - /// Checks if the RawJSON value is null. - var isNil: Bool { - switch self { - case .nil: - return true - default: - return false - } - } - - /// Extracts the wrapped value as the specified type, if possible. - /// - /// This method tries to cast the underlying RawJSON value to the requested - /// generic type `T`. Returns `nil` if the wrapped value does not match the - /// requested type. - /// - /// Example: - /// ``` - /// let json: RawJSON = .string("hello") - /// let value: String? = json.value() - /// ``` - /// - /// - Returns: The value as type `T` if compatible, otherwise `nil`. - func value() -> T? { - switch self { - case let .number(double): - // Handle all integer and floating-point conversions - if T.self == Int.self { return Int(double) as? T } - if T.self == Int32.self { return Int32(double) as? T } - if T.self == Int64.self { return Int64(double) as? T } - if T.self == UInt.self { return UInt(double) as? T } - if T.self == UInt32.self { return UInt32(double) as? T } - if T.self == UInt64.self { return UInt64(double) as? T } - if T.self == Double.self { return double as? T } - if T.self == Float.self { return Float(double) as? T } - // Fall back to cast (may work for NSNumber, etc) - return double as? T - case let .string(string): - return string as? T - case let .bool(bool): - return bool as? T - case let .dictionary(dictionary): - return dictionary as? T - case let .array(array): - return array as? T - case .nil: - return nil - } - } - - /// Extracts the wrapped value as the specified type, or returns a fallback. - /// - /// This method tries to cast the underlying RawJSON value to the requested - /// generic type `T`. If the cast fails, the `fallback` value is returned. - /// - /// Example: - /// ``` - /// let json: RawJSON = .number(42.0) - /// let value: Int = json.value(fallback: 0) // returns 0, as the value is a Double - /// ``` - /// - /// - Parameter fallback: The value to return if the cast fails. - /// - Returns: The value as type `T` if compatible, otherwise the fallback. - func value(fallback: T) -> T { - value() ?? fallback - } -} - -// MARK: ExpressibleByLiteral - -extension RawJSON: ExpressibleByDictionaryLiteral { - public typealias Key = String - public typealias Value = RawJSON - - /// RawJSON can be created by using a Dictionary Literal. - /// - /// Example: - /// ``` - /// let customData: [String: RawJSON] = [ - /// "flight": [ - /// "price": .number(1000), - /// "destination": .string("Lisbon") - /// ] - /// ] - /// ``` - public init(dictionaryLiteral elements: (String, RawJSON)...) { - let dict: [String: RawJSON] = elements.reduce(into: [:]) { partialResult, element in - partialResult[element.0] = element.1 - } - self = .dictionary(dict) - } -} - -extension RawJSON: ExpressibleByArrayLiteral { - /// RawJSON can be created by using an Array Literal. - /// - /// Example: - /// ``` - /// let customData: [String: RawJSON] = [ - /// "names": [.string("John"), string("Doe")] - /// ] - /// ``` - public init(arrayLiteral elements: RawJSON...) { - self = .array(elements) - } -} - -extension RawJSON: ExpressibleByStringLiteral { - /// RawJSON can be created by using a String Literal. - /// - /// Example: - /// ``` - /// let customData: [String: RawJSON] = [ - /// "names": ["John", "Doe"] // instead of [.string("John"), .string("Doe")] - /// ] - /// ``` - public init(stringLiteral value: StringLiteralType) { - self = .string(value) - } -} - -extension RawJSON: ExpressibleByIntegerLiteral, ExpressibleByFloatLiteral { - /// RawJSON can be created by using a Float Literal. - /// - /// Example: - /// ``` - /// let customData: [String: RawJSON] = [ - /// "distances": [3.5, 4.5] // instead of [.number(3.5), .number(3.5)] - /// ] - /// ``` - public init(floatLiteral value: FloatLiteralType) { - self = .number(value) - } - - /// RawJSON can be created by using an Integer Literal. - /// - /// Example: - /// ``` - /// let customData: [String: RawJSON] = [ - /// "ages": [23, 32] // instead of [.number(23.0), .number(32.0)] - /// ] - /// ``` - public init(integerLiteral value: IntegerLiteralType) { - self = .number(Double(value)) - } -} - -extension RawJSON: ExpressibleByBooleanLiteral { - /// RawJSON can be created by using a Bool Literal. - /// - /// Example: - /// ``` - /// let customData: [String: RawJSON] = [ - /// "isManager": true // instead of .bool(true) - /// ] - /// ``` - public init(booleanLiteral value: BooleanLiteralType) { - self = .bool(value) - } -} - -// MARK: Subscripts - -extension RawJSON { - /// Accesses the RawJSON as a dictionary with the given key for reading and writing. - /// This is specially useful for accessing nested types inside the extra data dictionary. - /// - /// Example: - /// ``` - /// let customData = message.customData - /// let price = customData["flight"]?["price"].numberValue - /// let destination = customData["flight"]?["destination"].stringValue - /// ``` - subscript(key: String) -> RawJSON? { - get { - guard case let .dictionary(dict) = self else { - return nil - } - - return dict[key] - } - set { - guard case var .dictionary(dict) = self else { - return - } - - dict[key] = newValue - self = .dictionary(dict) - } - } - - /// Accesses RawJSON as an array and accesses the element at the specified position. - /// This is specially useful for accessing arrays of nested types inside the extra data dictionary. - /// - /// Example: - /// ``` - /// let customData = message.customData - /// let secondFlightPrice = customData["flights"]?[1]?["price"] ?? 0 - /// ``` - subscript(index: Int) -> RawJSON? { - get { - guard case let .array(array) = self else { - return nil - } - - return array[index] - } - set { - guard case var .array(array) = self, let newValue = newValue else { - return - } - - array[index] = newValue - self = .array(array) - } - } -} - -extension RawJSON { - /// Initializes a `RawJSON` value from an `NSObject`. - /// - /// This is useful for converting Foundation types (e.g., from `NSDictionary`, - /// `NSArray`, `NSNumber`) into strongly typed `RawJSON` enums. - init(_ object: NSObject) { - switch object { - /// Converts NSString into a `.string` RawJSON value. - case let str as NSString: - self = .string(str as String) - /// Converts NSNumber into a `.number` RawJSON value. - case let num as NSNumber: - self = .number(num.doubleValue) - /// Converts NSArray into a `.array` of recursively converted RawJSON. - case let arr as NSArray: - let mappedArray = arr.compactMap { elem -> RawJSON? in - guard let elem = elem as? NSObject else { return nil } - return .init(elem) - } - self = .array(mappedArray) - /// Converts NSDictionary into a `.dictionary` of recursively converted RawJSON. - case let dict as NSDictionary: - var mappedDict = [String: RawJSON]() - dict.forEach { key, value in - if let keyStr = key as? String, let valueObj = value as? NSObject { - mappedDict[keyStr] = .init(valueObj) - } - } - self = .dictionary(mappedDict) - /// Fallback: uses the object's description as a `.string`. - default: - self = .string(object.description) - } - } -} diff --git a/Sources/StreamVideo/Utils/ReflectiveStringConvertible/ReflectiveStringConvertible.swift b/Sources/StreamVideo/Utils/ReflectiveStringConvertible/ReflectiveStringConvertible.swift deleted file mode 100644 index 75995c088..000000000 --- a/Sources/StreamVideo/Utils/ReflectiveStringConvertible/ReflectiveStringConvertible.swift +++ /dev/null @@ -1,230 +0,0 @@ -// -// Copyright © 2026 Stream.io Inc. All rights reserved. -// - -import Foundation -import protocol SwiftProtobuf.Message - -/// An enumeration representing rules for skipping properties during reflective -/// string conversion. -/// -/// These rules can be used to exclude properties based on specific conditions, -/// such as being empty, nil, or matching a custom rule. -public enum ReflectiveStringConvertibleSkipRule: Hashable { - /// Skip properties that are empty. - case empty - - /// Skip properties that are nil. - case nilValues - - /// Skip properties based on a custom rule. - /// - /// - Parameters: - /// - identifier: A unique identifier for the custom rule. - /// - rule: A closure that takes a `Mirror.Child` and returns a Boolean - /// indicating whether the property should be skipped. - case custom(identifier: String, rule: (Mirror.Child) -> Bool) - - /// Hashes the essential components of this value by feeding them into the - /// given hasher. - /// - /// - Parameter hasher: The hasher to use when combining the components of - /// this instance. - public func hash(into hasher: inout Hasher) { - switch self { - case .empty: - hasher.combine(".empty") - case .nilValues: - hasher.combine(".nilValues") - case let .custom(identifier, _): - hasher.combine(".custom_") - hasher.combine(identifier) - } - } - - /// Determines whether a given property should be skipped based on the rule. - /// - /// - Parameter child: A `Mirror.Child` representing the property to check. - /// - Returns: A Boolean indicating whether the property should be skipped. - public func shouldBeSkipped(_ child: Mirror.Child) -> Bool { - switch self { - case .empty: - if (child.value as? String)?.isEmpty == true { - return true - } else if (child.value as? (any Collection))?.isEmpty == true { - return true - } else { - return false - } - - case .nilValues: - return "\(child.value)" == "nil" - - case let .custom(_, rule): - return rule(child) - } - } - - /// Compares two `ReflectiveStringConvertibleSkipRule` values for equality. - /// - /// - Parameters: - /// - lhs: A `ReflectiveStringConvertibleSkipRule` value. - /// - rhs: Another `ReflectiveStringConvertibleSkipRule` value. - /// - Returns: A Boolean indicating whether the two values are equal. - public static func == ( - lhs: ReflectiveStringConvertibleSkipRule, - rhs: ReflectiveStringConvertibleSkipRule - ) -> Bool { - switch (lhs, rhs) { - case (.empty, .empty): - return true - case (.nilValues, .nilValues): - return true - case let (.custom(lhsIdentifier, _), .custom(rhsIdentifier, _)) where lhsIdentifier == rhsIdentifier: - return true - default: - return false - } - } -} - -/// An extension for collections of `ReflectiveStringConvertibleSkipRule` values. -extension Collection where Element == ReflectiveStringConvertibleSkipRule { - /// Determines whether a given property should be skipped based on the rules - /// in the collection. - /// - /// - Parameter element: A `Mirror.Child` representing the property to check. - /// - Returns: A Boolean indicating whether the property should be skipped. - func shouldBeSkipped(_ element: Mirror.Child) -> Bool { - reduce(false) { partialResult, rule in - guard !partialResult else { return partialResult } - return rule.shouldBeSkipped(element) - } - } -} - -/// A protocol that extends `CustomStringConvertible` to provide reflective string conversion capabilities. -/// -/// Types conforming to this protocol can customize their string representation by excluding specific properties -/// and leveraging Swift's reflection capabilities. -public protocol ReflectiveStringConvertible: CustomStringConvertible { - /// The separator used to join different parts of the string representation. - var separator: String { get } - - /// A set of property names to be excluded from the string representation. - var excludedProperties: Set { get } - - /// A set of property names to be transformed during the string representation. - var propertyTransformers: [String: (Any) -> String] { get } - - var skipRuleSet: Set { get } -} - -public extension ReflectiveStringConvertible { - var skipRuleSet: Set { - [.empty, .nilValues] - } - - /// The default separator used to join different parts of the string representation. - /// - /// By default, this is set to a newline character ("\n"). - var separator: String { ", " } - - /// The default set of properties to be excluded from the string representation. - /// - /// By default, this includes the "unknownFields" property. - var excludedProperties: Set { - [ - "unknownFields" - ] - } - - /// A dictionary of custom transformation functions for specific properties in the string representation. - /// - /// Each key in the dictionary corresponds to the name of a property that requires a custom transformation. - /// The associated value is a closure that takes the property value (`Any`) and returns a transformed `String`. - /// - /// The transformed string will be included in the final description of the object. - /// - /// By default, this includes a transformation for the `sdp` property, which replaces carriage return (`\r\n`) - /// characters with newline (`\n`) characters. - /// - /// - Example: - /// Suppose you have a property `sdp` that contains a multiline string with carriage return characters (`\r\n`). - /// You can use this transformer to normalize the line endings before including it in the description: - /// ``` - /// "sdp": { "\($0)".replacingOccurrences(of: "\r\n", with: "\n") } - /// ``` - /// - /// - Returns: A dictionary mapping property names to their respective transformation closures. - var propertyTransformers: [String: (Any) -> String] { - [ - "sdp": { "\($0)".replacingOccurrences(of: "\r\n", with: "\n") } - ] - } - - /// Generates a string representation of the conforming type using reflection. - /// - /// This implementation creates a detailed description of the object, including its type - /// and all non-excluded properties with their values. - /// - /// - Returns: A string representation of the object. - var description: String { - if let message = self as? Message { - #if STREAM_TESTS - // During tests we allow full logging. - #else - guard LogConfig.level == .debug else { - return "\(type(of: self))" - } - #endif - - let textFormat = message.textFormatString() - .trimmingCharacters(in: .whitespacesAndNewlines) - - guard !textFormat.isEmpty else { - return "\(type(of: self))" - } - - return "\(type(of: self)) { \(textFormat) }" - } - - #if STREAM_TESTS - // During tests we allow full error logging. - #else - guard LogConfig.level == .debug else { - return "\(type(of: self))" - } - #endif - return reflectiveDescription - } - - private var reflectiveDescription: String { - let mirror = Mirror(reflecting: self) - var result = "\(type(of: self))" - var components: [String] = [] - - let excludedProperties = self.excludedProperties - mirror - .children - .filter { !skipRuleSet.shouldBeSkipped($0) } - .compactMap { - if let label = $0.label, !excludedProperties.contains(label) { - let value = propertyTransformers[label]?($0.value) ?? $0.value - return (label: label, value: value) - } else { - return nil - } - } - .forEach { (child: (label: String, value: Any)) in - components.append("\(child.label):\(child.value)") - } - - if !components.isEmpty { - result += " { " - result += components.joined(separator: separator) - result += " }" - } - return result - } -} diff --git a/Sources/StreamVideo/Utils/StateMachine/StreamStateMachine.swift b/Sources/StreamVideo/Utils/StateMachine/StreamStateMachine.swift index 5cbd34c10..3dcea4656 100644 --- a/Sources/StreamVideo/Utils/StateMachine/StreamStateMachine.swift +++ b/Sources/StreamVideo/Utils/StateMachine/StreamStateMachine.swift @@ -4,6 +4,7 @@ import Combine import Foundation +import StreamCore /// A state machine that manages transitions between different stages. /// diff --git a/Sources/StreamVideo/Utils/Store/StoreStatistics.swift b/Sources/StreamVideo/Utils/Store/StoreStatistics.swift index 9d6f050a5..591e2d851 100644 --- a/Sources/StreamVideo/Utils/Store/StoreStatistics.swift +++ b/Sources/StreamVideo/Utils/Store/StoreStatistics.swift @@ -4,6 +4,7 @@ import Combine import Foundation +import StreamCore final class StoreStatistics { diff --git a/Sources/StreamVideo/Utils/StreamRuntimeCheck.swift b/Sources/StreamVideo/Utils/StreamRuntimeCheck.swift deleted file mode 100644 index e0608be06..000000000 --- a/Sources/StreamVideo/Utils/StreamRuntimeCheck.swift +++ /dev/null @@ -1,12 +0,0 @@ -// -// Copyright © 2026 Stream.io Inc. All rights reserved. -// - -import Foundation - -public enum StreamRuntimeCheck { - /// Enables assertions thrown by the StreamVideo SDK. - /// - /// When set to false, a message will be logged on console, but the assertion will not be thrown. - nonisolated(unsafe) static var assertionsEnabled = false -} diff --git a/Sources/StreamVideo/Utils/Swift6Migration/RawJSON+Double.swift b/Sources/StreamVideo/Utils/Swift6Migration/RawJSON+Double.swift deleted file mode 100644 index 7db1fee3c..000000000 --- a/Sources/StreamVideo/Utils/Swift6Migration/RawJSON+Double.swift +++ /dev/null @@ -1,13 +0,0 @@ -// -// Copyright © 2026 Stream.io Inc. All rights reserved. -// - -import Foundation - -extension RawJSON { - #if compiler(>=6.0) - public static let double = number - #else - public nonisolated(unsafe) static let double = number - #endif -} diff --git a/Sources/StreamVideo/Utils/Timers/DefaultTimer.swift b/Sources/StreamVideo/Utils/Timers/DefaultTimer.swift deleted file mode 100644 index 4a180edb5..000000000 --- a/Sources/StreamVideo/Utils/Timers/DefaultTimer.swift +++ /dev/null @@ -1,87 +0,0 @@ -// -// Copyright © 2026 Stream.io Inc. All rights reserved. -// - -import Combine -import Foundation - -/** - Default real-world implementations of the ``Timer`` protocol. - - These timers are based on ``DispatchQueue`` and ``DispatchSourceTimer``, - allowing precise control of scheduling on custom queues. This ensures that - timers are not bound to the main thread and can run in background contexts. - */ -public struct DefaultTimer: Timer { - /// Schedules a one-shot timer that fires once after the specified interval. - /// - /// The timer executes the ``onFire`` callback on the specified dispatch queue, - /// which can be any background or custom queue. Timers are not tied to the - /// main thread and can run on any queue you provide. - /// - /// - Parameters: - /// - timeInterval: Delay in seconds before the timer fires. - /// - queue: The dispatch queue on which to execute ``onFire``. This can be - /// any queue, including background queues. - /// - onFire: Callback to invoke when the timer fires. - /// - Returns: A ``TimerControl`` used to cancel the timer if needed. - @discardableResult - static func schedule( - timeInterval: TimeInterval, - queue: DispatchQueue, - onFire: @escaping () -> Void - ) -> TimerControl { - let worker = DispatchWorkItem(block: onFire) - queue.asyncAfter(deadline: .now() + timeInterval, execute: worker) - return worker - } - - /// Schedules a repeating timer on the given queue. - /// - /// The timer fires repeatedly at the specified interval. Execution happens - /// on the provided dispatch queue and is not tied to the main thread. You can - /// use any custom or background queue for timer events. - /// - /// - Parameters: - /// - timeInterval: Interval in seconds between timer firings. - /// - queue: The dispatch queue used to invoke the ``onFire`` callback. - /// - onFire: Callback to invoke on each timer fire. - /// - Returns: A ``RepeatingTimerControl`` used to manage the timer. - static func scheduleRepeating( - timeInterval: TimeInterval, - queue: DispatchQueue, - onFire: @escaping () -> Void - ) -> RepeatingTimerControl { - RepeatingTimer(timeInterval: timeInterval, queue: queue, onFire: onFire) - } - - /// Returns a Combine publisher that emits `Date` values at a fixed interval. - /// - /// The timer operates on a background queue and only emits values while - /// there are active subscribers. If the interval is less than or equal to - /// zero, a warning is logged and a single `Date` value is emitted instead. - /// - /// - Parameters: - /// - interval: Time between emitted date values. - /// - file: The file from which the method is called. Used for logging. - /// - function: The function from which the method is called. - /// - line: The line number from which the method is called. - /// - Returns: A publisher that emits dates while subscribed. - public static func publish( - every interval: TimeInterval, - file: StaticString = #file, - function: StaticString = #function, - line: UInt = #line - ) -> AnyPublisher { - guard interval > 0 else { - log.warning( - "Interval cannot be 0 or less", - functionName: function, - fileName: file, - lineNumber: line - ) - return Just(Date()).eraseToAnyPublisher() - } - return TimerPublisher(interval: interval).eraseToAnyPublisher() - } -} diff --git a/Sources/StreamVideo/Utils/Timers/Extensions/DispatchWorkItem+TimerControl.swift b/Sources/StreamVideo/Utils/Timers/Extensions/DispatchWorkItem+TimerControl.swift deleted file mode 100644 index 11ed421aa..000000000 --- a/Sources/StreamVideo/Utils/Timers/Extensions/DispatchWorkItem+TimerControl.swift +++ /dev/null @@ -1,7 +0,0 @@ -// -// Copyright © 2026 Stream.io Inc. All rights reserved. -// - -import Foundation - -extension DispatchWorkItem: TimerControl {} diff --git a/Sources/StreamVideo/Utils/Timers/Protocol/Timer.swift b/Sources/StreamVideo/Utils/Timers/Protocol/Timer.swift deleted file mode 100644 index e071c7045..000000000 --- a/Sources/StreamVideo/Utils/Timers/Protocol/Timer.swift +++ /dev/null @@ -1,77 +0,0 @@ -// -// Copyright © 2026 Stream.io Inc. All rights reserved. -// - -import Combine -import Foundation - -/// A protocol describing a timing utility for scheduling one-shot and repeating -/// timers, as well as retrieving the current time or publishing timer events. -/// -/// All scheduled timers are executed on the provided dispatch queue, which -/// allows running timers off the main thread in background contexts. -protocol Timer { - - /// Schedules a new one-shot timer on the specified queue. - /// - /// The timer fires once after the given time interval. The `onFire` callback - /// is invoked on the provided dispatch queue, which is not restricted to the - /// main thread. - /// - /// - Parameters: - /// - timeInterval: Seconds until the timer fires. - /// - queue: The queue where the `onFire` callback is executed. It can be a - /// background queue. - /// - onFire: Callback triggered when the timer fires. - /// - Returns: A `TimerControl` that can cancel the scheduled timer. - @discardableResult - static func schedule( - timeInterval: TimeInterval, - queue: DispatchQueue, - onFire: @escaping () -> Void - ) -> TimerControl - - /// Schedules a new repeating timer on the specified queue. - /// - /// The timer repeatedly fires after the specified time interval. The `onFire` - /// callback is invoked on the provided dispatch queue, which can be a - /// background queue and is not tied to the main thread. - /// - /// - Parameters: - /// - timeInterval: Seconds between repeated timer firings. - /// - queue: The queue on which the `onFire` callback is executed. - /// - onFire: Callback triggered each time the timer fires. - /// - Returns: A `RepeatingTimerControl` that allows suspension and resumption. - static func scheduleRepeating( - timeInterval: TimeInterval, - queue: DispatchQueue, - onFire: @escaping () -> Void - ) -> RepeatingTimerControl - - /// Returns the current system date and time. - static func currentTime() -> Date - - /// Returns a publisher that emits timer events at the specified interval. - /// - /// This publisher is designed to emit values from a background context and - /// is not restricted to the main thread. - /// - /// - Parameters: - /// - interval: Time between emitted `Date` values. - /// - repeating: A Boolean indicating if the timer should repeat. - /// - Returns: A publisher that emits the current `Date` on each fire. - static func publish( - every interval: TimeInterval, - file: StaticString, - function: StaticString, - line: UInt - ) -> AnyPublisher -} - -extension Timer { - - /// Returns the current system date and time. - static func currentTime() -> Date { - Date() - } -} diff --git a/Sources/StreamVideo/Utils/Timers/Protocol/TimerControl.swift b/Sources/StreamVideo/Utils/Timers/Protocol/TimerControl.swift deleted file mode 100644 index d6951e3b6..000000000 --- a/Sources/StreamVideo/Utils/Timers/Protocol/TimerControl.swift +++ /dev/null @@ -1,11 +0,0 @@ -// -// Copyright © 2026 Stream.io Inc. All rights reserved. -// - -import Foundation - -/// Allows cancelling a timer. -protocol TimerControl { - /// Cancels the timer. - func cancel() -} diff --git a/Sources/StreamVideo/Utils/Timers/Repeating/RepeatingTimer.swift b/Sources/StreamVideo/Utils/Timers/Repeating/RepeatingTimer.swift deleted file mode 100644 index d57ce73d2..000000000 --- a/Sources/StreamVideo/Utils/Timers/Repeating/RepeatingTimer.swift +++ /dev/null @@ -1,100 +0,0 @@ -// -// Copyright © 2026 Stream.io Inc. All rights reserved. -// - -import Foundation - -/// A repeating timer implementation using `DispatchSourceTimer`, conforming to -/// `RepeatingTimerControl`. It safely supports resuming and suspending while -/// operating on a background queue. -/// -/// This timer operates off the main thread using a custom dispatch queue. -final class RepeatingTimer: RepeatingTimerControl, @unchecked Sendable { - /// Represents the internal running state of the timer. - private enum State { - case suspended - case resumed - } - - /// The internal queue on which the timer state transitions and control - /// operations are serialized. - private let queue = DispatchQueue(label: "io.getstream.repeating-timer") - - /// Tracks whether the timer is currently suspended or running. - private var state: State = .suspended - - /// The underlying GCD timer source. - private let timer: DispatchSourceTimer - - /// Indicates whether the timer is currently active and running. - var isRunning: Bool { state == .resumed } - - /// Initializes a new repeating timer that fires at the given interval on the - /// specified queue. - /// - /// - Parameters: - /// - timeInterval: The interval at which the timer fires repeatedly. - /// - queue: The dispatch queue on which the timer's `onFire` handler will - /// be invoked. This queue can be a background queue. - /// - onFire: The callback invoked each time the timer fires. - init( - timeInterval: TimeInterval, - queue: DispatchQueue, - onFire: @escaping () -> Void - ) { - timer = DispatchSource.makeTimerSource(queue: queue) - timer.schedule( - deadline: .now() + .seconds(Int(timeInterval)), - repeating: timeInterval, - leeway: .seconds(1) - ) - timer.setEventHandler(handler: onFire) - } - - /// Cancels the timer and performs cleanup. - /// - /// If the timer is still suspended, it must first be resumed before - /// cancelling to avoid a crash. This is a known behavior of GCD timers. - deinit { - timer.setEventHandler {} - timer.cancel() - // If the timer is suspended, calling cancel without resuming - // triggers a crash. This is documented here: - // https://forums.developer.apple.com/thread/15902 - if state == .suspended { - timer.resume() - } - } - - /// Resumes the timer if it was previously suspended. - /// - /// This method is thread-safe and dispatched to an internal serial queue. - func resume() { - queue.async { - if self.state == .resumed { - return - } - - self.state = .resumed - self.timer.resume() - } - } - - /// Suspends the timer if it is currently running. - /// - /// This method is thread-safe and dispatched to an internal serial queue. - func suspend() { - queue.async { - if self.state == .suspended { - return - } - - self.state = .suspended - self.timer.suspend() - } - } - - func cancel() { - suspend() - } -} diff --git a/Sources/StreamVideo/Utils/Timers/Repeating/RepeatingTimerControl.swift b/Sources/StreamVideo/Utils/Timers/Repeating/RepeatingTimerControl.swift deleted file mode 100644 index 6c8bd72cd..000000000 --- a/Sources/StreamVideo/Utils/Timers/Repeating/RepeatingTimerControl.swift +++ /dev/null @@ -1,23 +0,0 @@ -// -// Copyright © 2026 Stream.io Inc. All rights reserved. -// - -import Foundation - -/// A protocol for controlling a repeating timer, allowing inspection and -/// management of its running state. -protocol RepeatingTimerControl: TimerControl { - /// Resumes the timer if it was previously suspended. - /// - /// If the timer is already running, this call has no effect. - func resume() - - /// Suspends the timer if it is currently running. - /// - /// If the timer is already suspended, this call has no effect. - func suspend() - - /// A Boolean value indicating whether the timer is currently active and - /// executing on schedule. - var isRunning: Bool { get } -} diff --git a/Sources/StreamVideo/Utils/Timers/TimerPublisher/TimerPublisher.swift b/Sources/StreamVideo/Utils/Timers/TimerPublisher/TimerPublisher.swift deleted file mode 100644 index c02ba8166..000000000 --- a/Sources/StreamVideo/Utils/Timers/TimerPublisher/TimerPublisher.swift +++ /dev/null @@ -1,97 +0,0 @@ -// -// Copyright © 2026 Stream.io Inc. All rights reserved. -// - -import Combine -import Foundation - -/// A custom Combine publisher that emits `Date` values at a fixed interval. -/// -/// The timer emits values from a background queue and only runs when there is -/// at least one active subscriber. It automatically suspends when no -/// subscribers remain. -final class TimerPublisher: Publisher { - typealias Output = Date - typealias Failure = Never - - /// The interval at which the timer fires, in seconds. - private let interval: TimeInterval - - /// Creates a new instance of `TimerPublisher` with the specified interval. - /// - /// - Parameter interval: The interval in seconds between published dates. - init(interval: TimeInterval) { - self.interval = interval - } - - /// Registers a subscriber and starts emitting dates on a background queue. - func receive(subscriber: S) where S: Subscriber, Failure == S.Failure, Output == S.Input { - subscriber.receive( - subscription: TimerSubscription( - subscriber: subscriber, - interval: interval - ) - ) - } -} - -extension TimerPublisher { - - /// A subscription wrapper that handles timer events and lifecycle. - /// - /// Emits `Date` values to the subscriber while the timer is active. It - /// ensures the timer is only started once and safely suspended on cancel. - private final class TimerSubscription: Subscription where S.Input == Date, S.Failure == Never { - /// The downstream subscriber receiving the date values. - private var subscriber: S? - - /// The repeating timer control that emits values at a fixed interval. - private var control: RepeatingTimerControl? - - /// The timestamp marking when the subscription was initialized. We keep a reference - /// to check when the first firing of our Timer block will occur. If it's before the interval we are - /// skipping upstream call. - private let registeringTime: Date = .init() - - /// Initializes the subscription and starts the timer. - /// - /// - Parameters: - /// - subscriber: The subscriber to receive emitted date values. - /// - interval: The interval in seconds between emissions. - init( - subscriber: S, - interval: TimeInterval - ) { - self.subscriber = subscriber - control = DefaultTimer.scheduleRepeating( - timeInterval: interval, - queue: .global(qos: .default), - onFire: { [weak self] in - let value = Date() - guard - let self, - value.timeIntervalSince(registeringTime) >= interval - else { - return - } - _ = subscriber.receive(value) - } - ) - control?.resume() - } - - /// Requests values from the publisher. - /// - /// This is ignored because values are pushed on a fixed schedule. - func request(_ demand: Subscribers.Demand) { - // demand is ignored because we send Date events on a schedule - } - - /// Cancels the timer and cleans up the subscriber reference. - func cancel() { - subscriber = nil - control?.suspend() - control = nil - } - } -} diff --git a/Sources/StreamVideo/WebRTC/WebRTCEventDecoder.swift b/Sources/StreamVideo/WebRTC/WebRTCEventDecoder.swift index 8d67af0c4..5732f8c85 100644 --- a/Sources/StreamVideo/WebRTC/WebRTCEventDecoder.swift +++ b/Sources/StreamVideo/WebRTC/WebRTCEventDecoder.swift @@ -3,14 +3,18 @@ // import Foundation +import StreamCore +/// Decodes raw SFU WebSocket frames into events for `WebSocketClient`. struct WebRTCEventDecoder: AnyEventDecoder { - - func decode(from data: Data) throws -> WrappedEvent { + + func decode(from data: Data) throws -> Event { let response = try Stream_Video_Sfu_Event_SfuEvent(serializedBytes: data) guard let payload = response.eventPayload else { - throw ClientError.UnsupportedEventType() + // Unknown/empty payloads are skipped (logged, not surfaced) by the + // client's decode loop when they throw `IgnoredEventType`. + throw ClientError.IgnoredEventType() } - return .sfuEvent(payload) + return payload } } diff --git a/Sources/StreamVideo/WebRTC/WebRTCEvents.swift b/Sources/StreamVideo/WebRTC/WebRTCEvents.swift index d075a9fbe..82dbab66d 100644 --- a/Sources/StreamVideo/WebRTC/WebRTCEvents.swift +++ b/Sources/StreamVideo/WebRTC/WebRTCEvents.swift @@ -21,7 +21,6 @@ extension Stream_Video_Sfu_Event_JoinResponse: Event {} extension Stream_Video_Sfu_Event_HealthCheckResponse: Event {} extension Stream_Video_Sfu_Event_SfuRequest: SendableEvent {} extension Stream_Video_Sfu_Event_HealthCheckRequest: SendableEvent {} -extension Stream_Video_Sfu_Event_HealthCheckResponse: HealthCheck {} extension Stream_Video_Sfu_Event_TrackPublished: Event {} extension Stream_Video_Sfu_Event_TrackUnpublished: Event {} extension Stream_Video_Sfu_Event_Error: Event {} diff --git a/Sources/StreamVideo/WebRTC/v2/Extensions/Foundation/Publisher+TaskSink.swift b/Sources/StreamVideo/WebRTC/v2/Extensions/Foundation/Publisher+TaskSink.swift index f245d21d3..ebe866787 100644 --- a/Sources/StreamVideo/WebRTC/v2/Extensions/Foundation/Publisher+TaskSink.swift +++ b/Sources/StreamVideo/WebRTC/v2/Extensions/Foundation/Publisher+TaskSink.swift @@ -4,6 +4,7 @@ import Combine import Foundation +import StreamCore /// Extension to `Publisher` providing utility methods for handling asynchronous /// tasks and managing their lifecycle using `Combine`. diff --git a/Sources/StreamVideo/WebRTC/v2/PeerConnection/Adapters/ICEConnectionStateAdapter.swift b/Sources/StreamVideo/WebRTC/v2/PeerConnection/Adapters/ICEConnectionStateAdapter.swift index 533f0aa24..3e54e3669 100644 --- a/Sources/StreamVideo/WebRTC/v2/PeerConnection/Adapters/ICEConnectionStateAdapter.swift +++ b/Sources/StreamVideo/WebRTC/v2/PeerConnection/Adapters/ICEConnectionStateAdapter.swift @@ -4,6 +4,7 @@ import Combine import Foundation +import StreamCore import StreamWebRTC /// An adapter that observes ICE connection state changes from the WebRTC peer diff --git a/Sources/StreamVideo/WebRTC/v2/SFU/Protocols/SignalServerEvent.swift b/Sources/StreamVideo/WebRTC/v2/SFU/Protocols/SignalServerEvent.swift index d57ea8a11..a81c6674a 100644 --- a/Sources/StreamVideo/WebRTC/v2/SFU/Protocols/SignalServerEvent.swift +++ b/Sources/StreamVideo/WebRTC/v2/SFU/Protocols/SignalServerEvent.swift @@ -3,6 +3,7 @@ // import Foundation +import StreamCore protocol SignalServerEvent: ReflectiveStringConvertible {} diff --git a/Sources/StreamVideo/WebRTC/v2/SFU/Protocols/WebSocketClientProviding.swift b/Sources/StreamVideo/WebRTC/v2/SFU/Protocols/WebSocketClientProviding.swift deleted file mode 100644 index 83af72d3d..000000000 --- a/Sources/StreamVideo/WebRTC/v2/SFU/Protocols/WebSocketClientProviding.swift +++ /dev/null @@ -1,64 +0,0 @@ -// -// Copyright © 2026 Stream.io Inc. All rights reserved. -// - -import Foundation - -/// A protocol defining methods for creating WebSocket clients. -protocol WebSocketClientProviding { - - /// Builds and returns a WebSocket client with the specified parameters. - /// - Parameters: - /// - sessionConfiguration: The URL session configuration. - /// - eventDecoder: The decoder for parsing events. - /// - eventNotificationCenter: The notification center for events. - /// - webSocketClientType: The type of WebSocket client. - /// - environment: The environment for the WebSocket client. - /// - connectURL: The URL to connect to. - /// - requiresAuth: Whether authentication is required. - /// - Returns: A configured WebSocketClient instance. - func build( - sessionConfiguration: URLSessionConfiguration, - eventDecoder: AnyEventDecoder, - eventNotificationCenter: EventNotificationCenter, - webSocketClientType: WebSocketClientType, - environment: WebSocketClient.Environment, - connectURL: URL, - requiresAuth: Bool - ) -> WebSocketClient -} - -/// A concrete implementation of `WebSocketClientProviding`. -final class WebSocketClientFactory: WebSocketClientProviding { - - /// Builds and returns a WebSocket client with the specified parameters. - /// - Parameters: - /// - sessionConfiguration: The URL session configuration. - /// - eventDecoder: The decoder for parsing events. - /// - eventNotificationCenter: The notification center for events. - /// - webSocketClientType: The type of WebSocket client. - /// - environment: The environment for the WebSocket client. - /// Defaults to a new instance. - /// - connectURL: The URL to connect to. - /// - requiresAuth: Whether authentication is required. Defaults to true. - /// - Returns: A configured WebSocketClient instance. - func build( - sessionConfiguration: URLSessionConfiguration, - eventDecoder: AnyEventDecoder, - eventNotificationCenter: EventNotificationCenter, - webSocketClientType: WebSocketClientType, - environment: WebSocketClient.Environment = .init(), - connectURL: URL, - requiresAuth: Bool = true - ) -> WebSocketClient { - .init( - sessionConfiguration: sessionConfiguration, - eventDecoder: eventDecoder, - eventNotificationCenter: eventNotificationCenter, - webSocketClientType: webSocketClientType, - environment: environment, - connectURL: connectURL, - requiresAuth: requiresAuth - ) - } -} diff --git a/Sources/StreamVideo/WebRTC/v2/SFU/SFUAdapter.swift b/Sources/StreamVideo/WebRTC/v2/SFU/SFUAdapter.swift index ab1b5ea8e..b87702ad5 100644 --- a/Sources/StreamVideo/WebRTC/v2/SFU/SFUAdapter.swift +++ b/Sources/StreamVideo/WebRTC/v2/SFU/SFUAdapter.swift @@ -4,12 +4,22 @@ import Combine import Foundation +import StreamCore /// A class that manages the communication with a Selective Forwarding Unit (SFU) for video streaming. /// /// The `SFUAdapter` class handles both WebSocket connections and HTTP requests to the SFU server. /// It provides methods for managing video tracks, updating subscriptions, and handling WebRTC signaling. -final class SFUAdapter: ConnectionStateDelegate, CustomStringConvertible, @unchecked Sendable { +/// +/// The SFU signaling WebSocket is backed by `WebSocketClient` via +/// ``SFUWebSocket``. +final class SFUAdapter: CustomStringConvertible, @unchecked Sendable { + + typealias WebSocketFactory = @Sendable ( + _ url: URL, + _ sessionConfiguration: URLSessionConfiguration, + _ environment: WebSocketClient.Environment + ) -> SFUWebSocket /// Configuration for the SFU service. struct ServiceConfiguration { @@ -29,19 +39,22 @@ final class SFUAdapter: ConnectionStateDelegate, CustomStringConvertible, @unche struct WebSocketConfiguration { /// The URL for the WebSocket connection. var url: URL - /// The event notification center for handling WebSocket events. - var eventNotificationCenter: EventNotificationCenter /// The session configuration for the WebSocket connection. Defaults to not waiting for connectivity. var sessionConfiguration: URLSessionConfiguration = .default.toggleWaitsForConnectivity(false) - /// The decoder for WebRTC events. Defaults to a standard WebRTCEventDecoder. - var eventDecoder: WebRTCEventDecoder = WebRTCEventDecoder() } private let processingQueue = DispatchQueue(label: "io.getstream.sfu.event.processingQueue") private let signalService: SFUSignalService private let refreshSubject = PassthroughSubject() - private let webSocketFactory: WebSocketClientProviding - private var webSocket: WebSocketClient + // Stable relay that keeps existing subscribers alive when refresh replaces + // the underlying WebSocket and setUpPublishers reconnects its event stream. + private let eventSubject = PassthroughSubject< + Stream_Video_Sfu_Event_SfuEvent.OneOf_EventPayload, + Never + >() + /// The active SFU signaling socket. + private var webSocket: SFUWebSocket + private let webSocketFactory: WebSocketFactory private var disposableBag = DisposableBag() private var requestDisposableBag = DisposableBag() private var isConnected: Bool { @@ -54,6 +67,8 @@ final class SFUAdapter: ConnectionStateDelegate, CustomStringConvertible, @unche } /// The current connection state of the WebSocket. + /// + /// Mirrors ``SFUWebSocket``'s state (see ``setUpPublishers()``). @Published private(set) var connectionState: WebSocketConnectionState = .initialized /// A publisher that is used to inform subscribers that the SFUAdapter has refreshed its webSocket @@ -72,17 +87,7 @@ final class SFUAdapter: ConnectionStateDelegate, CustomStringConvertible, @unche /// A Combine publisher that allows observation of *all events* received by the adapter. var publisher: AnyPublisher { - webSocket - .eventSubject - .compactMap { - switch $0 { - case let .sfuEvent(event): - return event - default: - return nil - } - } - .eraseToAnyPublisher() + eventSubject.eraseToAnyPublisher() } private let subjectSendEvent: PassthroughSubject = .init() @@ -92,8 +97,7 @@ final class SFUAdapter: ConnectionStateDelegate, CustomStringConvertible, @unche var description: String { """ - Address: \(Unmanaged.passUnretained(webSocket).toOpaque()) - SFUAdapter is delegate: \(webSocket.connectionStateDelegate === self) + Address: \(ObjectIdentifier(webSocket)) ConnectionState: \(connectionState) ConnectURL: \(connectURL) Hostname: \(hostname) @@ -109,7 +113,6 @@ final class SFUAdapter: ConnectionStateDelegate, CustomStringConvertible, @unche serviceConfiguration: ServiceConfiguration, webSocketConfiguration: WebSocketConfiguration ) { - let webSocketFactory = WebSocketClientFactory() self.init( signalService: .init( httpClient: serviceConfiguration.httpClient, @@ -117,36 +120,35 @@ final class SFUAdapter: ConnectionStateDelegate, CustomStringConvertible, @unche hostname: serviceConfiguration.url.absoluteString, token: serviceConfiguration.token ), - webSocket: webSocketFactory.build( - sessionConfiguration: webSocketConfiguration.sessionConfiguration, - eventDecoder: webSocketConfiguration.eventDecoder, - eventNotificationCenter: webSocketConfiguration.eventNotificationCenter, - webSocketClientType: .sfu, - connectURL: webSocketConfiguration.url, - requiresAuth: false - ), - webSocketFactory: webSocketFactory + webSocket: SFUWebSocket( + url: webSocketConfiguration.url, + sessionConfiguration: webSocketConfiguration.sessionConfiguration + ) ) } init( signalService: SFUSignalService, - webSocket: WebSocketClient, - webSocketFactory: WebSocketClientProviding + webSocket: SFUWebSocket, + webSocketFactory: @escaping WebSocketFactory = { + SFUWebSocket( + url: $0, + sessionConfiguration: $1, + environment: $2 + ) + } ) { self.signalService = signalService self.webSocket = webSocket self.webSocketFactory = webSocketFactory - webSocket.connectionStateDelegate = self - setUpPublishers() } deinit { disposableBag.removeAll() requestDisposableBag.removeAll() - webSocket.disconnect {} + webSocket.disconnect(code: .normalClosure) } // MARK: - WebSocket @@ -164,16 +166,8 @@ final class SFUAdapter: ConnectionStateDelegate, CustomStringConvertible, @unche function: StaticString = #function, line: UInt = #line ) -> AnyPublisher { - webSocket - .eventSubject - .compactMap { - switch $0 { - case let .sfuEvent(event): - return event.payload(T.self) - default: - return nil - } - } + eventSubject + .compactMap { $0.payload(T.self) } .eraseToAnyPublisher() } @@ -199,18 +193,16 @@ final class SFUAdapter: ConnectionStateDelegate, CustomStringConvertible, @unche /// Sends a health check request to the WebSocket server. func sendHealthCheck() { statusCheck() - webSocket - .engine? - .send(message: Stream_Video_Sfu_Event_HealthCheckRequest()) + webSocket.send(Stream_Video_Sfu_Event_HealthCheckRequest()) } - /// Sends a message through the WebSocket connection. + /// Sends an SFU request through the WebSocket connection. /// - /// - Parameter message: The message to be sent, conforming to the SendableEvent protocol. - func send(message: SendableEvent) { + /// - Parameter message: The SFU request to be sent. + func send(message: Stream_Video_Sfu_Event_SfuRequest) { statusCheck() log.debug(message, subsystems: .sfu) - webSocket.engine?.send(message: message) + webSocket.send(message) } /// Refreshes the WebSocket connection with a new configuration. @@ -223,19 +215,13 @@ final class SFUAdapter: ConnectionStateDelegate, CustomStringConvertible, @unche webSocketConfiguration: WebSocketConfiguration ) { log.debug("Will refresh \(self).", subsystems: .sfu) - webSocket.connectionStateDelegate = nil - webSocket.disconnect(code: .init(rawValue: 4002)!) {} + webSocket.disconnectForReconfiguration() requestDisposableBag.removeAll() - webSocket = webSocketFactory.build( - sessionConfiguration: webSocketConfiguration.sessionConfiguration, - eventDecoder: webSocketConfiguration.eventDecoder, - eventNotificationCenter: webSocketConfiguration.eventNotificationCenter, - webSocketClientType: .sfu, - environment: .init(), - connectURL: webSocketConfiguration.url, - requiresAuth: false + webSocket = webSocketFactory( + webSocketConfiguration.url, + webSocketConfiguration.sessionConfiguration, + webSocket.environment ) - webSocket.connectionStateDelegate = self refreshSubject.send(()) @@ -305,7 +291,8 @@ final class SFUAdapter: ConnectionStateDelegate, CustomStringConvertible, @unche "\(events.endIndex) event(s) of type \(eventType) found in bucket and will consume on sfuAdapter:\(self).", subsystems: .sfu ) - events.forEach { webSocket.eventSubject.send(.sfuEvent($0)) } + // Route replayed payloads through the same publisher as live events. + events.forEach { webSocket.inject($0) } } // MARK: - Service @@ -606,34 +593,6 @@ final class SFUAdapter: ConnectionStateDelegate, CustomStringConvertible, @unche } } - // MARK: - ConnectionStateDelegate - - /// Updates the connection state of the SFUAdapter when the WebSocket connection state changes. - /// - /// This method is part of the `ConnectionStateDelegate` protocol implementation. It's called - /// by the WebSocketClient whenever the connection state changes, allowing the SFUAdapter - /// to keep track of the current WebSocket connection state. - /// - /// - Parameters: - /// - client: The WebSocketClient that triggered the state update. - /// - state: The new WebSocketConnectionState. - /// - /// - Note: This method updates the `connectionState` property of the SFUAdapter, - /// which is a published property that observers can react to. - func webSocketClient( - _ client: WebSocketClient, - didUpdateConnectionState state: WebSocketConnectionState - ) { - log.debug( - """ - WebSocket connectionState changed to \(state) - client: \(Unmanaged.passUnretained(client).toOpaque()) - """, - subsystems: .sfu - ) - connectionState = state - } - // MARK: - Private helpers private func setUpPublishers() { @@ -645,14 +604,19 @@ final class SFUAdapter: ConnectionStateDelegate, CustomStringConvertible, @unche .store(in: disposableBag) webSocket - .eventSubject + .connectionStatePublisher + .sink { [weak self] in self?.connectionState = $0 } + .store(in: disposableBag) + + webSocket + .eventPublisher .log(.debug, subsystems: .sfu) { """ SFU received event \($0) """ } - .sink { _ in } + .sink { [weak self] in self?.eventSubject.send($0) } .store(in: disposableBag) signalService diff --git a/Sources/StreamVideo/WebRTC/v2/SFU/SFUEvent+StreamCore.swift b/Sources/StreamVideo/WebRTC/v2/SFU/SFUEvent+StreamCore.swift new file mode 100644 index 000000000..bb8113002 --- /dev/null +++ b/Sources/StreamVideo/WebRTC/v2/SFU/SFUEvent+StreamCore.swift @@ -0,0 +1,28 @@ +// +// Copyright © 2026 Stream.io Inc. All rights reserved. +// + +import StreamCore + +extension Stream_Video_Sfu_Event_SfuEvent.OneOf_EventPayload { + public func healthcheck() -> HealthCheckInfo? { + if case let .healthCheckResponse(event) = self { + return HealthCheckInfo(participantCount: Int(event.participantCount.total)) + } + return nil + } + + public func error() -> Error? { + if case let .error(event) = self { + return event.error + } + return nil + } +} + +/// Builds the SFU health-check ping sent periodically by `WebSocketClient`. +func makeSFUHealthCheckPing() -> any SendableEvent { + var request = Stream_Video_Sfu_Event_SfuRequest() + request.healthCheckRequest = Stream_Video_Sfu_Event_HealthCheckRequest() + return request +} diff --git a/Sources/StreamVideo/WebRTC/v2/SFU/SFUWebSocket.swift b/Sources/StreamVideo/WebRTC/v2/SFU/SFUWebSocket.swift new file mode 100644 index 000000000..0e80420b6 --- /dev/null +++ b/Sources/StreamVideo/WebRTC/v2/SFU/SFUWebSocket.swift @@ -0,0 +1,121 @@ +// +// Copyright © 2026 Stream.io Inc. All rights reserved. +// + +import Combine +import Foundation +import StreamCore + +/// Owns the SFU signaling WebSocket, backed by `WebSocketClient`. +/// +/// Narrows inbound events to SFU payloads. +class SFUWebSocket: @unchecked Sendable { + + private let webSocket: WebSocketClient + let environment: WebSocketClient.Environment + + /// The current connection state. + var connectionState: WebSocketConnectionState { webSocket.connectionState } + + var connectionStatePublisher: AnyPublisher { + webSocket.connectionStatePublisher + } + + /// The URL used for the WebSocket connection. + let connectURL: URL + + /// Emits every inbound SFU event payload. + var eventPublisher: AnyPublisher { + webSocket + .eventSubject + .compactMap { $0 as? Stream_Video_Sfu_Event_SfuEvent.OneOf_EventPayload } + .eraseToAnyPublisher() + } + + init( + url: URL, + sessionConfiguration: URLSessionConfiguration, + environment: WebSocketClient.Environment = .init( + eventBatchingPeriod: 0 + ) + ) { + connectURL = url + self.environment = environment + webSocket = WebSocketClient( + sessionConfiguration: sessionConfiguration, + eventDecoder: WebRTCEventDecoder(), + eventNotificationCenter: DefaultEventNotificationCenter(), + webSocketClientType: .sfu, + environment: environment, + connectRequest: URLRequest(url: url), + healthCheckBeforeConnected: false, + requiresAuth: false, + // Keep SFU health checks below the call state's 15-second timeout. + pingInterval: 5, + closeCodeProvider: SFUWebSocketCloseCodeProvider(), + pingRequestBuilder: { makeSFUHealthCheckPing() } + ) + } + + func connect() { + webSocket.connect() + } + + func disconnect() async { + await webSocket.disconnect() + } + + func disconnect(code: URLSessionWebSocketTask.CloseCode) { + webSocket.disconnect(code: code, source: .userInitiated) {} + } + + func disconnectForReconfiguration() { + webSocket.disconnect(context: .reconfiguration) {} + } + + /// Sends an outbound SFU message over the WebSocket. + func send(_ message: any SendableEvent) { + webSocket.engine?.send(message: message) + } + + /// Re-injects a buffered SFU payload into the event stream (replay path). + func inject(_ payload: Stream_Video_Sfu_Event_SfuEvent.OneOf_EventPayload) { + webSocket.eventSubject.send(payload) + } +} + +struct SFUWebSocketCloseCodeProvider: WebSocketCloseCodeProviding { + private static let connectionUnhealthy = URLSessionWebSocketTask.CloseCode( + rawValue: 4001 + )! + private static let reconfiguration = URLSessionWebSocketTask.CloseCode( + rawValue: 4002 + )! + + func closeCode( + for context: WebSocketCloseContext + ) -> URLSessionWebSocketTask.CloseCode { + switch context { + case .disconnection(source: .noPongReceived): + // 4001 identifies the health-check timeout across Stream SDKs. The + // SFU treats every non-1000/non-1001 code as an abnormal close and + // preserves participant state during its reconnect grace period. + return Self.connectionUnhealthy + case .reconfiguration: + // 4002 identifies a Swift socket replacement. It is not reserved by + // the SFU; using a custom code selects the abnormal-close path and + // avoids immediate participant teardown. + return Self.reconfiguration + case let .explicit(code, _): + // Preserve callers that intentionally use a protocol close code. + return code + case .disconnection: + // Preserve intentional-disconnect behavior. A normal closure tells + // the SFU it can tear down participant state immediately. + return .normalClosure + @unknown default: + // A future StreamCore context is not implicitly recoverable. + return .normalClosure + } + } +} diff --git a/Sources/StreamVideo/WebRTC/v2/StateMachine/Stages/WebRTCCoordinator+Disconnected.swift b/Sources/StreamVideo/WebRTC/v2/StateMachine/Stages/WebRTCCoordinator+Disconnected.swift index 29f45b435..2a2dfafe5 100644 --- a/Sources/StreamVideo/WebRTC/v2/StateMachine/Stages/WebRTCCoordinator+Disconnected.swift +++ b/Sources/StreamVideo/WebRTC/v2/StateMachine/Stages/WebRTCCoordinator+Disconnected.swift @@ -4,6 +4,7 @@ import Combine import Foundation +import StreamCore extension WebRTCCoordinator.StateMachine.Stage { diff --git a/Sources/StreamVideo/WebRTC/v2/StateMachine/Stages/WebRTCCoordinator+FastReconnecting.swift b/Sources/StreamVideo/WebRTC/v2/StateMachine/Stages/WebRTCCoordinator+FastReconnecting.swift index 2887a078f..ccb2cfed6 100644 --- a/Sources/StreamVideo/WebRTC/v2/StateMachine/Stages/WebRTCCoordinator+FastReconnecting.swift +++ b/Sources/StreamVideo/WebRTC/v2/StateMachine/Stages/WebRTCCoordinator+FastReconnecting.swift @@ -74,8 +74,7 @@ extension WebRTCCoordinator.StateMachine.Stage { log.debug("Refreshing webSocket", subsystems: .webRTC) sfuAdapter.refresh( webSocketConfiguration: .init( - url: sfuAdapter.connectURL, - eventNotificationCenter: .init() + url: sfuAdapter.connectURL ) ) diff --git a/Sources/StreamVideo/WebRTC/v2/StateMachine/Stages/WebRTCCoordinator+Joined.swift b/Sources/StreamVideo/WebRTC/v2/StateMachine/Stages/WebRTCCoordinator+Joined.swift index 1d00f4e3a..4d74f6683 100644 --- a/Sources/StreamVideo/WebRTC/v2/StateMachine/Stages/WebRTCCoordinator+Joined.swift +++ b/Sources/StreamVideo/WebRTC/v2/StateMachine/Stages/WebRTCCoordinator+Joined.swift @@ -4,6 +4,7 @@ import Combine import Foundation +import StreamCore extension WebRTCCoordinator.StateMachine.Stage { @@ -221,7 +222,9 @@ extension WebRTCCoordinator.StateMachine.Stage { .sink { [weak self] (source: WebSocketConnectionState.DisconnectionSource) in guard let self else { return } context.disconnectionSource = source - if let sfuError = (source.serverError?.underlyingError as? Stream_Video_Sfu_Models_Error) { + if let sfuError = source + .serverError? + .underlyingError as? Stream_Video_Sfu_Models_Error { context.reconnectionStrategy = sfuError.shouldRetry ? .fast( disconnectedSince: .init(), @@ -509,9 +512,8 @@ extension WebRTCCoordinator.StateMachine.Stage { deadline: context.fastReconnectDeadlineSeconds ) - /// Set the disconnection source as server-initiated due to a network error. context.disconnectionSource = .serverInitiated( - error: .NetworkError("Not available") + error: ClientError.NetworkError("Not available") ) log.warning( diff --git a/Sources/StreamVideo/WebRTC/v2/StateMachine/Stages/WebRTCCoordinator+Stage.swift b/Sources/StreamVideo/WebRTC/v2/StateMachine/Stages/WebRTCCoordinator+Stage.swift index 76677c12a..dda7a31a4 100644 --- a/Sources/StreamVideo/WebRTC/v2/StateMachine/Stages/WebRTCCoordinator+Stage.swift +++ b/Sources/StreamVideo/WebRTC/v2/StateMachine/Stages/WebRTCCoordinator+Stage.swift @@ -4,6 +4,7 @@ import Combine import Foundation +import StreamCore extension WebRTCCoordinator.StateMachine { /// Represents a stage in the WebRTC coordinator state machine. diff --git a/Sources/StreamVideo/WebRTC/v2/Stats/Collector/WebRTCStatsCollector.swift b/Sources/StreamVideo/WebRTC/v2/Stats/Collector/WebRTCStatsCollector.swift index 0eb55ac5b..19f5643c5 100644 --- a/Sources/StreamVideo/WebRTC/v2/Stats/Collector/WebRTCStatsCollector.swift +++ b/Sources/StreamVideo/WebRTC/v2/Stats/Collector/WebRTCStatsCollector.swift @@ -4,6 +4,7 @@ import Combine import Foundation +import StreamCore import StreamWebRTC /// A collector responsible for gathering WebRTC call statistics. diff --git a/Sources/StreamVideo/WebRTC/v2/Stats/Models/WebRTCTrace.swift b/Sources/StreamVideo/WebRTC/v2/Stats/Models/WebRTCTrace.swift index 5f217c3d1..c90991fdb 100644 --- a/Sources/StreamVideo/WebRTC/v2/Stats/Models/WebRTCTrace.swift +++ b/Sources/StreamVideo/WebRTC/v2/Stats/Models/WebRTCTrace.swift @@ -160,6 +160,8 @@ extension WebRTCTrace { return "network.state.online" case .unavailable, .unknown: return "network.state.offline" + @unknown default: + return "network.state.offline" } }() self.init( diff --git a/Sources/StreamVideo/WebRTC/v2/Stats/Reporter/WebRTCStatsReporter.swift b/Sources/StreamVideo/WebRTC/v2/Stats/Reporter/WebRTCStatsReporter.swift index e8f191997..6090fe67d 100644 --- a/Sources/StreamVideo/WebRTC/v2/Stats/Reporter/WebRTCStatsReporter.swift +++ b/Sources/StreamVideo/WebRTC/v2/Stats/Reporter/WebRTCStatsReporter.swift @@ -4,6 +4,7 @@ import Combine import Foundation +import StreamCore import StreamWebRTC /// A class responsible for collecting and reporting WebRTC statistics. @@ -148,7 +149,7 @@ final class WebRTCStatsReporter: WebRTCStatsReporting, @unchecked Sendable { try Task.checkCancellation() let tracesData = try JSONEncoder - .stream + .streamCore .encode(input.peerConnectionTraces) let traces = String(data: tracesData, encoding: .utf8) diff --git a/Sources/StreamVideo/WebRTC/v2/Stats/WebRTCStatsAdapter.swift b/Sources/StreamVideo/WebRTC/v2/Stats/WebRTCStatsAdapter.swift index 9183a4435..8db2f1b2b 100644 --- a/Sources/StreamVideo/WebRTC/v2/Stats/WebRTCStatsAdapter.swift +++ b/Sources/StreamVideo/WebRTC/v2/Stats/WebRTCStatsAdapter.swift @@ -4,6 +4,7 @@ import Combine import Foundation +import StreamCore import StreamWebRTC /// Collects, compresses, and reports WebRTC statistics and traces. diff --git a/Sources/StreamVideo/WebRTC/v2/WebRTCAuthenticator.swift b/Sources/StreamVideo/WebRTC/v2/WebRTCAuthenticator.swift index bccbb094b..469823433 100644 --- a/Sources/StreamVideo/WebRTC/v2/WebRTCAuthenticator.swift +++ b/Sources/StreamVideo/WebRTC/v2/WebRTCAuthenticator.swift @@ -157,8 +157,7 @@ struct WebRTCAuthenticator: WebRTCAuthenticating { url: try unwrap( .init(string: response.credentials.server.wsEndpoint), errorMessage: "WebSocket URL is invalid." - ), - eventNotificationCenter: .init() + ) ) ) diff --git a/Sources/StreamVideo/WebSockets/Client/APIKey.swift b/Sources/StreamVideo/WebSockets/Client/APIKey.swift deleted file mode 100644 index 2accada38..000000000 --- a/Sources/StreamVideo/WebSockets/Client/APIKey.swift +++ /dev/null @@ -1,17 +0,0 @@ -/// A struct representing an API key of the chat app. -/// -/// An API key can be obtained by registering on [our website](https://getstream.io/chat/trial/\). -/// -public struct APIKey: Equatable { - /// The string representation of the API key - public let apiKeyString: String - - /// Creates a new `APIKey` from the provided string. Fails, if the string is empty. - /// - /// - Warning: The `apiKeyString` must be a non-empty value, otherwise an assertion failure is raised. - /// - public init(_ apiKeyString: String) { - log.assert(apiKeyString.isEmpty == false, "APIKey can't be initialize with an empty string.", subsystems: .webSocket) - self.apiKeyString = apiKeyString - } -} diff --git a/Sources/StreamVideo/WebSockets/Client/BackgroundTaskScheduler.swift b/Sources/StreamVideo/WebSockets/Client/BackgroundTaskScheduler.swift deleted file mode 100644 index 4981f9b8b..000000000 --- a/Sources/StreamVideo/WebSockets/Client/BackgroundTaskScheduler.swift +++ /dev/null @@ -1,122 +0,0 @@ -// -// Copyright © 2026 Stream.io Inc. All rights reserved. -// - -import Foundation - -/// Object responsible for platform specific handling of background tasks -protocol BackgroundTaskScheduler: Sendable { - /// It's your responsibility to finish previously running task. - /// - /// Returns: `false` if system forbid background task, `true` otherwise - @MainActor - func beginTask(expirationHandler: (@Sendable () -> Void)?) -> Bool - @MainActor - func endTask() - @MainActor - func startListeningForAppStateUpdates( - onEnteringBackground: @escaping () -> Void, - onEnteringForeground: @escaping () -> Void - ) - @MainActor - func stopListeningForAppStateUpdates() - - var isAppActive: Bool { get } -} - -#if os(iOS) -import UIKit - -class IOSBackgroundTaskScheduler: BackgroundTaskScheduler, @unchecked Sendable { - @Injected(\.applicationStateAdapter) private var applicationStateAdapter - - private lazy var app: UIApplication? = { - // We can't use `UIApplication.shared` directly because there's no way to convince the compiler - // this code is accessible only for non-extension executables. - UIApplication.value(forKeyPath: "sharedApplication") as? UIApplication - }() - - /// The identifier of the currently running background task. `nil` if no background task is running. - private var activeBackgroundTask: UIBackgroundTaskIdentifier? - - var isAppActive: Bool { applicationStateAdapter.state == .foreground } - - @MainActor - func beginTask(expirationHandler: (@Sendable () -> Void)?) -> Bool { - activeBackgroundTask = app?.beginBackgroundTask { [weak self] in - expirationHandler?() - self?.endTask() - } - return activeBackgroundTask != .invalid - } - - @MainActor - func endTask() { - if let activeTask = activeBackgroundTask { - app?.endBackgroundTask(activeTask) - activeBackgroundTask = nil - } - } - - private var onEnteringBackground: () -> Void = {} - private var onEnteringForeground: () -> Void = {} - - func startListeningForAppStateUpdates( - onEnteringBackground: @escaping () -> Void, - onEnteringForeground: @escaping () -> Void - ) { - self.onEnteringForeground = onEnteringForeground - self.onEnteringBackground = onEnteringBackground - - NotificationCenter.default.addObserver( - self, - selector: #selector(handleAppDidEnterBackground), - name: UIApplication.didEnterBackgroundNotification, - object: nil - ) - - NotificationCenter.default.addObserver( - self, - selector: #selector(handleAppDidBecomeActive), - name: UIApplication.didBecomeActiveNotification, - object: nil - ) - } - - func stopListeningForAppStateUpdates() { - onEnteringForeground = {} - onEnteringBackground = {} - - NotificationCenter.default.removeObserver( - self, - name: UIApplication.didEnterBackgroundNotification, - object: nil - ) - - NotificationCenter.default.removeObserver( - self, - name: UIApplication.didBecomeActiveNotification, - object: nil - ) - } - - @objc private func handleAppDidEnterBackground() { - onEnteringBackground() - } - - @objc private func handleAppDidBecomeActive() { - onEnteringForeground() - } - - deinit { - // swiftlint:disable discourage_task_init - Task { @MainActor [activeBackgroundTask, app] in - if let activeTask = activeBackgroundTask { - app?.endBackgroundTask(activeTask) - } - } - // swiftlint:enable discourage_task_init - } -} - -#endif diff --git a/Sources/StreamVideo/WebSockets/Client/ConnectionRecoveryHandler.swift b/Sources/StreamVideo/WebSockets/Client/ConnectionRecoveryHandler.swift deleted file mode 100644 index e564aaf70..000000000 --- a/Sources/StreamVideo/WebSockets/Client/ConnectionRecoveryHandler.swift +++ /dev/null @@ -1,346 +0,0 @@ -// -// Copyright © 2026 Stream.io Inc. All rights reserved. -// - -import CoreData -import Foundation - -/// The type that keeps track of active chat components and asks them to reconnect when it's needed -protocol ConnectionRecoveryHandler: ConnectionStateDelegate, Sendable {} - -/// The type is designed to obtain missing events that happened in watched channels while user -/// was not connected to the web-socket. -/// -/// When the status becomes `connected` the `/sync` endpoint is called -/// with `lastReceivedEventDate` and `cids` of watched channels. -/// -/// We remember `lastReceivedEventDate` when state becomes `connecting` to catch the last event date -/// before the `HealthCheck` override the `lastReceivedEventDate` with the recent date. -/// -final class DefaultConnectionRecoveryHandler: ConnectionRecoveryHandler, @unchecked Sendable { - // MARK: - Properties - - private let webSocketClient: WebSocketClient - private let eventNotificationCenter: EventNotificationCenter - private let backgroundTaskScheduler: BackgroundTaskScheduler? - private let internetConnection: InternetConnection - private let reconnectionTimerType: Timer.Type - private let keepConnectionAliveInBackground: Bool - private let disposableBag = DisposableBag() - private nonisolated(unsafe) var reconnectionStrategy: RetryStrategy - private nonisolated(unsafe) var reconnectionTimer: TimerControl? - private nonisolated(unsafe) var reconnectionPolicies: [AutomaticReconnectionPolicy] - - // MARK: - Init - - convenience init( - webSocketClient: WebSocketClient, - eventNotificationCenter: EventNotificationCenter, - backgroundTaskScheduler: BackgroundTaskScheduler?, - internetConnection: InternetConnection, - reconnectionStrategy: RetryStrategy, - reconnectionTimerType: Timer.Type, - keepConnectionAliveInBackground: Bool - ) { - self.init( - webSocketClient: webSocketClient, - eventNotificationCenter: eventNotificationCenter, - backgroundTaskScheduler: backgroundTaskScheduler, - internetConnection: internetConnection, - reconnectionStrategy: reconnectionStrategy, - reconnectionTimerType: reconnectionTimerType, - keepConnectionAliveInBackground: keepConnectionAliveInBackground, - reconnectionPolicies: [ - WebSocketAutomaticReconnectionPolicy(webSocketClient), - InternetAvailabilityReconnectionPolicy(internetConnection), - CompositeReconnectionPolicy(.or, policies: [ - BackgroundStateReconnectionPolicy(backgroundTaskScheduler), - CallKitReconnectionPolicy() - ]) - ] - ) - } - - init( - webSocketClient: WebSocketClient, - eventNotificationCenter: EventNotificationCenter, - backgroundTaskScheduler: BackgroundTaskScheduler?, - internetConnection: InternetConnection, - reconnectionStrategy: RetryStrategy, - reconnectionTimerType: Timer.Type, - keepConnectionAliveInBackground: Bool, - reconnectionPolicies: [AutomaticReconnectionPolicy] - ) { - self.webSocketClient = webSocketClient - self.eventNotificationCenter = eventNotificationCenter - self.backgroundTaskScheduler = backgroundTaskScheduler - self.internetConnection = internetConnection - self.reconnectionStrategy = reconnectionStrategy - self.reconnectionTimerType = reconnectionTimerType - self.keepConnectionAliveInBackground = keepConnectionAliveInBackground - self.reconnectionPolicies = reconnectionPolicies - - subscribeOnNotifications() - } - - deinit { - unsubscribeFromNotifications() - cancelReconnectionTimer() - } -} - -// MARK: - Subscriptions - -private extension DefaultConnectionRecoveryHandler { - func subscribeOnNotifications() { - Task(disposableBag: disposableBag) { @MainActor [weak self] in - guard let self else { return } - backgroundTaskScheduler?.startListeningForAppStateUpdates( - onEnteringBackground: { [weak self] in self?.appDidEnterBackground() }, - onEnteringForeground: { [weak self] in self?.appDidBecomeActive() } - ) - - internetConnection.notificationCenter.addObserver( - self, - selector: #selector(internetConnectionAvailabilityDidChange(_:)), - name: .internetConnectionAvailabilityDidChange, - object: nil - ) - } - } - - func unsubscribeFromNotifications() { - Task(disposableBag: disposableBag) { @MainActor [backgroundTaskScheduler] in - backgroundTaskScheduler?.stopListeningForAppStateUpdates() - } - - internetConnection.notificationCenter.removeObserver( - self, - name: .internetConnectionStatusDidChange, - object: nil - ) - } -} - -// MARK: - Event handlers - -extension DefaultConnectionRecoveryHandler { - private func appDidBecomeActive() { - Task(disposableBag: disposableBag) { @MainActor [weak self] in - log.debug("App -> ✅", subsystems: .webSocket) - - self?.backgroundTaskScheduler?.endTask() - - self?.reconnectIfNeeded() - } - } - - private func appDidEnterBackground() { - log.debug("App -> 💤", subsystems: .webSocket) - - guard canBeDisconnected else { - // Client is not trying to connect nor connected - return - } - - guard keepConnectionAliveInBackground else { - // We immediately disconnect - disconnectIfNeeded() - return - } - - guard let scheduler = backgroundTaskScheduler else { return } - - Task(disposableBag: disposableBag) { @MainActor [weak self] in - let succeed = scheduler.beginTask { [weak self] in - log.debug("Background task -> ❌", subsystems: .webSocket) - - self?.disconnectIfNeeded() - } - - if succeed { - log.debug("Background task -> ✅", subsystems: .webSocket) - } else { - // Can't initiate a background task, close the connection - self?.disconnectIfNeeded() - } - } - } - - @objc private func internetConnectionAvailabilityDidChange(_ notification: Notification) { - guard let isAvailable = notification.internetConnectionStatus?.isAvailable else { return } - - log.debug("Internet -> \(isAvailable ? "✅" : "❌")", subsystems: .webSocket) - - if isAvailable { - reconnectIfNeeded() - } else { - disconnectIfNeeded() - } - } - - func webSocketClient(_ client: WebSocketClient, didUpdateConnectionState state: WebSocketConnectionState) { - log.debug("Connection state: \(state)", subsystems: .webSocket) - - switch state { - case .connecting: - cancelReconnectionTimer() - - case .connected: - reconnectionStrategy.resetConsecutiveFailures() - case .disconnected: - scheduleReconnectionTimerIfNeeded() - case .initialized, .authenticating, .disconnecting: - break - } - } -} - -// MARK: - Disconnection - -private extension DefaultConnectionRecoveryHandler { - func disconnectIfNeeded() { - guard canBeDisconnected else { return } - - webSocketClient.disconnect(source: .systemInitiated) { - log.debug("Did disconnect automatically", subsystems: .webSocket) - } - } - - var canBeDisconnected: Bool { - let state = webSocketClient.connectionState - - switch state { - case .connecting, .authenticating, .connected: - log.debug("Will disconnect automatically from \(state) state", subsystems: .webSocket) - - return true - default: - log.debug("Disconnect is not needed in \(state) state", subsystems: .webSocket) - - return false - } - } -} - -// MARK: - Reconnection - -private extension DefaultConnectionRecoveryHandler { - func reconnectIfNeeded() { - guard canReconnectAutomatically else { return } - - webSocketClient.connect() - } - - var canReconnectAutomatically: Bool { - reconnectionPolicies.first { $0.canBeReconnected() == false } == nil - } -} - -// MARK: - Reconnection Timer - -private extension DefaultConnectionRecoveryHandler { - func scheduleReconnectionTimerIfNeeded() { - guard canReconnectAutomatically else { return } - - scheduleReconnectionTimer() - } - - func scheduleReconnectionTimer() { - let delay = reconnectionStrategy.getDelayAfterTheFailure() - - log.debug("Timer ⏳ \(delay) sec", subsystems: .webSocket) - - reconnectionTimer = reconnectionTimerType.schedule( - timeInterval: delay, - queue: .main, - onFire: { [weak self] in - log.debug("Timer 🔥", subsystems: .webSocket) - - self?.reconnectIfNeeded() - } - ) - } - - func cancelReconnectionTimer() { - guard reconnectionTimer != nil else { return } - - log.debug("Timer ❌", subsystems: .webSocket) - - reconnectionTimer?.cancel() - reconnectionTimer = nil - } -} - -// MARK: - Automatic Reconnection Policies - -protocol AutomaticReconnectionPolicy { - func canBeReconnected() -> Bool -} - -struct WebSocketAutomaticReconnectionPolicy: AutomaticReconnectionPolicy { - private var webSocketClient: WebSocketClient - - init(_ webSocketClient: WebSocketClient) { - self.webSocketClient = webSocketClient - } - - func canBeReconnected() -> Bool { - webSocketClient.connectionState.isAutomaticReconnectionEnabled - } -} - -struct InternetAvailabilityReconnectionPolicy: AutomaticReconnectionPolicy { - private var internetConnection: InternetConnection - - init(_ internetConnection: InternetConnection) { - self.internetConnection = internetConnection - } - - func canBeReconnected() -> Bool { - internetConnection.status.isAvailable - } -} - -struct BackgroundStateReconnectionPolicy: AutomaticReconnectionPolicy { - private var backgroundTaskScheduler: BackgroundTaskScheduler? - - init(_ backgroundTaskScheduler: BackgroundTaskScheduler?) { - self.backgroundTaskScheduler = backgroundTaskScheduler - } - - func canBeReconnected() -> Bool { - backgroundTaskScheduler?.isAppActive ?? true - } -} - -struct CallKitReconnectionPolicy: AutomaticReconnectionPolicy { - @Injected(\.callKitService) private var callKitService - - init() {} - - func canBeReconnected() -> Bool { - callKitService.callCount > 0 - } -} - -struct CompositeReconnectionPolicy: AutomaticReconnectionPolicy { - enum Operator { case and, or } - - private var `operator`: Operator - private var policies: [AutomaticReconnectionPolicy] - - init(_ operator: Operator, policies: [AutomaticReconnectionPolicy]) { - self.operator = `operator` - self.policies = policies - } - - func canBeReconnected() -> Bool { - switch `operator` { - case .and: - return policies.first { $0.canBeReconnected() == false } == nil - case .or: - return policies.first { $0.canBeReconnected() } != nil - } - } -} diff --git a/Sources/StreamVideo/WebSockets/Client/ConnectionStatus.swift b/Sources/StreamVideo/WebSockets/Client/ConnectionStatus.swift deleted file mode 100644 index e2eeaa06a..000000000 --- a/Sources/StreamVideo/WebSockets/Client/ConnectionStatus.swift +++ /dev/null @@ -1,146 +0,0 @@ -// -// Copyright © 2026 Stream.io Inc. All rights reserved. -// - -import Foundation - -// `ConnectionStatus` is just a simplified and friendlier wrapper around `WebSocketConnectionState`. - -/// Describes the possible states of the client connection to the servers. -public enum ConnectionStatus: Equatable, Sendable { - /// The client is initialized but not connected to the remote server yet. - case initialized - - /// The client is disconnected. This is an initial state. Optionally contains an error, if the connection was disconnected - /// due to an error. - case disconnected(error: ClientError? = nil) - - /// The client is in the process of connecting to the remote servers. - case connecting - - /// The client is connected to the remote server. - case connected - - /// The web socket is disconnecting. - case disconnecting -} - -extension ConnectionStatus { - // In internal initializer used for convering internal `WebSocketConnectionState` to `ChatClientConnectionStatus`. - init(webSocketConnectionState: WebSocketConnectionState) { - switch webSocketConnectionState { - case .initialized: - self = .initialized - - case .connecting, .authenticating: - self = .connecting - - case .connected: - self = .connected - - case .disconnecting: - self = .disconnecting - - case let .disconnected(source): - let isWaitingForReconnect = webSocketConnectionState.isAutomaticReconnectionEnabled || source.serverError? - .isInvalidTokenError == true - - self = isWaitingForReconnect ? .connecting : .disconnected(error: source.serverError) - } - } -} - -typealias ConnectionId = String - -/// A web socket connection state. -enum WebSocketConnectionState: Equatable { - /// Provides additional information about the source of disconnecting. - enum DisconnectionSource: Equatable { - /// A user initiated web socket disconnecting. - case userInitiated - - /// A server initiated web socket disconnecting, an optional error object is provided. - case serverInitiated(error: ClientError? = nil) - - /// The system initiated web socket disconnecting. - case systemInitiated - - /// `WebSocketPingController` didn't get a pong response. - case noPongReceived - - /// Returns the underlaying error if connection cut was initiated by the server. - var serverError: ClientError? { - guard case let .serverInitiated(error) = self else { return nil } - - return error - } - } - - /// The initial state meaning that there was no atempt to connect yet. - case initialized - - /// The web socket is not connected. Contains the source/reason why the disconnection has happened. - case disconnected(source: DisconnectionSource) - - /// The web socket is connecting. - case connecting - - /// The web socket is connected, client is authenticating. - case authenticating - - /// The web socket was connected. - case connected(healthCheckInfo: HealthCheckInfo) - - /// The web socket is disconnecting. `source` contains more info about the source of the event. - case disconnecting(source: DisconnectionSource) - - /// Checks if the connection state is connected. - var isConnected: Bool { - if case .connected = self { - return true - } - return false - } - - /// Returns false if the connection state is in the `notConnected` state. - var isActive: Bool { - if case .disconnected = self { - return false - } - return true - } - - /// Returns `true` is the state requires and allows automatic reconnection. - var isAutomaticReconnectionEnabled: Bool { - guard case let .disconnected(source) = self else { return false } - - switch source { - case let .serverInitiated(clientError): - if let wsEngineError = clientError?.underlyingError as? WebSocketEngineError, - wsEngineError.code == WebSocketEngineError.stopErrorCode { - // Don't reconnect on `stop` errors - return false - } - - if let serverInitiatedError = clientError?.underlyingError as? ErrorPayload { - if serverInitiatedError.isInvalidTokenError { - // Don't reconnect on invalid token errors - return false - } - - if serverInitiatedError.isClientError { - // Don't reconnect on client side errors - return false - } - } - - return true - case .systemInitiated: - return true - case .noPongReceived: - return true - case .userInitiated: - return false - } - } -} diff --git a/Sources/StreamVideo/WebSockets/Client/Coordinator/CoordinatorWebSocket.swift b/Sources/StreamVideo/WebSockets/Client/Coordinator/CoordinatorWebSocket.swift new file mode 100644 index 000000000..2b1283495 --- /dev/null +++ b/Sources/StreamVideo/WebSockets/Client/Coordinator/CoordinatorWebSocket.swift @@ -0,0 +1,120 @@ +// +// Copyright © 2026 Stream.io Inc. All rights reserved. +// + +import Combine +import Foundation +import StreamCore + +/// Owns the coordinator signaling WebSocket, backed by `WebSocketClient`. +/// +/// It decodes coordinator events through the shared notification center and +/// sends StreamVideo's authentication payload when the connection opens. +final class CoordinatorWebSocket: + ConnectionStateDelegate, + @unchecked Sendable { + + private let webSocket: WebSocketClient + /// Builds the connect payload (video's `WSAuthMessageRequest`) sent once the + /// socket connects. Provided by the caller since it needs the current + /// user/token; returns `nil` if the caller is gone. + private let connectPayloadProvider: () -> (any Codable)? + private let recoveryHandler: ConnectionRecoveryHandler + + var connectionState: WebSocketConnectionState { webSocket.connectionState } + + var connectionStatePublisher: AnyPublisher { + webSocket.connectionStatePublisher + } + + init( + url: URL, + eventNotificationCenter: EventNotificationCenter, + sessionConfiguration: URLSessionConfiguration = .default, + connectPayloadProvider: @escaping () -> (any Codable)?, + hasActiveCall: @escaping @Sendable () -> Bool + ) { + self.connectPayloadProvider = connectPayloadProvider + + let webSocket = WebSocketClient( + sessionConfiguration: sessionConfiguration, + eventDecoder: JsonEventDecoder(), + eventNotificationCenter: eventNotificationCenter, + webSocketClientType: .coordinator, + connectRequest: URLRequest(url: url), + healthCheckBeforeConnected: false, + requiresAuth: true, + pingInterval: 5 + ) + self.webSocket = webSocket + + // StreamCore's recovery handler drives reconnection off the underlying + // client. Compose its policies with the video-specific CallKit policy: + // internet AND wsAuto AND (background OR CallKit). + let backgroundTaskScheduler = Self.makeBackgroundTaskScheduler() + let internetConnection = InternetConnection( + monitor: InternetConnection.Monitor() + ) + recoveryHandler = DefaultConnectionRecoveryHandler( + webSocketClient: webSocket, + eventNotificationCenter: eventNotificationCenter, + backgroundTaskScheduler: backgroundTaskScheduler, + internetConnection: internetConnection, + reconnectionStrategy: DefaultRetryStrategy(), + reconnectionTimerType: DefaultTimer.self, + keepConnectionAliveInBackground: true, + reconnectionPolicies: [ + WebSocketAutomaticReconnectionPolicy(webSocket), + InternetAvailabilityReconnectionPolicy(internetConnection), + CompositeReconnectionPolicy(.or, policies: [ + BackgroundStateReconnectionPolicy(backgroundTaskScheduler), + CallKitReconnectionPolicy(hasActiveCall: hasActiveCall) + ]) + ] + ) + + webSocket.connectionStateDelegate = self + webSocket.onWSConnectionEstablished = { [weak self] in + guard let self, let payload = self.connectPayloadProvider() else { return } + self.webSocket.engine?.send(jsonMessage: payload) + } + } + + private static func makeBackgroundTaskScheduler() -> BackgroundTaskScheduler? { + guard !Bundle.main.isAppExtension else { return nil } + #if os(iOS) + return IOSBackgroundTaskScheduler() + #else + return nil + #endif + } + + func connect() { + webSocket.connect() + } + + func disconnect() async { + await webSocket.disconnect() + } + + // MARK: - ConnectionStateDelegate + + func webSocketClient( + _ client: WebSocketClient, + didUpdateConnectionState state: WebSocketConnectionState + ) { + if case let .disconnected(source) = state, + let serverError = source.serverError, + serverError.isInvalidTokenError || serverError.isTokenExpiredError { + // StreamVideo refreshes the token from the state publisher and + // reconnects explicitly. Do not let Core reconnect with the stale + // token in parallel. + return + } + + recoveryHandler.webSocketClient( + client, + didUpdateConnectionState: state + ) + } +} diff --git a/Sources/StreamVideo/WebSockets/Client/Coordinator/Reconnection/CallKitReconnectionPolicy.swift b/Sources/StreamVideo/WebSockets/Client/Coordinator/Reconnection/CallKitReconnectionPolicy.swift new file mode 100644 index 000000000..c1af5a94e --- /dev/null +++ b/Sources/StreamVideo/WebSockets/Client/Coordinator/Reconnection/CallKitReconnectionPolicy.swift @@ -0,0 +1,25 @@ +// +// Copyright © 2026 Stream.io Inc. All rights reserved. +// + +import Foundation +import StreamCore + +/// Allows reconnection while there's an active CallKit call. +/// +/// This is the video-specific policy StreamCore's recovery handler lacks; it lets +/// the coordinator socket recover in the background during a VoIP call. The +/// "has active call" check is injected as a closure because `callKitService` is +/// resolved via video's DI (`@Injected`), which is ambiguous inside a +/// StreamCore-importing file. +struct CallKitReconnectionPolicy: AutomaticReconnectionPolicy { + private let hasActiveCall: @Sendable () -> Bool + + init(hasActiveCall: @escaping @Sendable () -> Bool) { + self.hasActiveCall = hasActiveCall + } + + func canBeReconnected() -> Bool { + hasActiveCall() + } +} diff --git a/Sources/StreamVideo/WebSockets/Client/RetryStrategy.swift b/Sources/StreamVideo/WebSockets/Client/RetryStrategy.swift deleted file mode 100644 index 425b114bb..000000000 --- a/Sources/StreamVideo/WebSockets/Client/RetryStrategy.swift +++ /dev/null @@ -1,68 +0,0 @@ -// -// Copyright © 2026 Stream.io Inc. All rights reserved. -// - -import Foundation - -/// The type encapsulating the logic of computing delays for the failed actions that needs to be retried. -protocol RetryStrategy: Sendable { - /// Returns the # of consecutively failed retries. - var consecutiveFailuresCount: Int { get } - - /// Increments the # of consecutively failed retries making the next delay longer. - mutating func incrementConsecutiveFailures() - - /// Resets the # of consecutively failed retries making the next delay be the shortest one. - mutating func resetConsecutiveFailures() - - /// Calculates and returns the delay for the next retry. - /// - /// Consecutive calls after the same # of failures may return different delays. This randomization is done to - /// make the retry intervals slightly different for different callers to avoid putting the backend down by - /// making all the retries at the same time. - /// - /// - Returns: The delay for the next retry. - func nextRetryDelay() -> TimeInterval -} - -extension RetryStrategy { - /// Returns the delay and then increments # of consecutively failed retries. - /// - /// - Returns: The delay for the next retry. - mutating func getDelayAfterTheFailure() -> TimeInterval { - defer { incrementConsecutiveFailures() } - - return nextRetryDelay() - } -} - -/// The default implementation of `RetryStrategy` with exponentially growing delays. -struct DefaultRetryStrategy: RetryStrategy { - static let maximumReconnectionDelay: TimeInterval = 25 - - @Atomic private(set) var consecutiveFailuresCount = 0 - - mutating func incrementConsecutiveFailures() { - _consecutiveFailuresCount.mutate { $0 + 1 } - } - - mutating func resetConsecutiveFailures() { - consecutiveFailuresCount = 0 - } - - func nextRetryDelay() -> TimeInterval { - var delay: TimeInterval = 0 - - let consecutiveFailuresCount = self.consecutiveFailuresCount - /// The first time we get to retry, we do it without any delay. Any subsequent time will - /// be delayed by a random interval. - guard consecutiveFailuresCount > 0 else { return delay } - - let maxDelay: TimeInterval = min(0.5 + Double(consecutiveFailuresCount * 2), Self.maximumReconnectionDelay) - let minDelay: TimeInterval = min(max(0.25, (Double(consecutiveFailuresCount) - 1) * 2), Self.maximumReconnectionDelay) - - delay = TimeInterval.random(in: minDelay...maxDelay) - - return delay - } -} diff --git a/Sources/StreamVideo/WebSockets/Client/StreamCore+ConnectionStatus.swift b/Sources/StreamVideo/WebSockets/Client/StreamCore+ConnectionStatus.swift new file mode 100644 index 000000000..7e5db7e9c --- /dev/null +++ b/Sources/StreamVideo/WebSockets/Client/StreamCore+ConnectionStatus.swift @@ -0,0 +1,25 @@ +// +// Copyright © 2026 Stream.io Inc. All rights reserved. +// + +import StreamCore + +/// StreamVideo-specific mappings for StreamCore's connection status. +extension ConnectionStatus { + /// Creates a connection status while preserving StreamVideo's token + /// refresh flow. + /// + /// StreamVideo owns invalid-token recovery, so it remains in the + /// connecting state while it refreshes the token and reconnects. All other + /// states use StreamCore's default mapping. + init( + videoWebSocketConnectionState state: WebSocketConnectionState + ) { + if case let .disconnected(source) = state, + source.serverError?.isInvalidTokenError == true { + self = .connecting + } else { + self.init(webSocketConnectionState: state) + } + } +} diff --git a/Sources/StreamVideo/WebSockets/Client/URLSessionWebSocketEngine.swift b/Sources/StreamVideo/WebSockets/Client/URLSessionWebSocketEngine.swift deleted file mode 100644 index 3c9a0cd95..000000000 --- a/Sources/StreamVideo/WebSockets/Client/URLSessionWebSocketEngine.swift +++ /dev/null @@ -1,215 +0,0 @@ -// -// Copyright © 2026 Stream.io Inc. All rights reserved. -// - -import Foundation - -final class URLSessionWebSocketEngine: NSObject, WebSocketEngine, @unchecked Sendable { - private weak var task: URLSessionWebSocketTask? { - didSet { - oldValue?.cancel() - } - } - - let request: URLRequest - private var session: URLSession? - let delegateOperationQueue: OperationQueue - let sessionConfiguration: URLSessionConfiguration - var urlSessionDelegateHandler: URLSessionDelegateHandler? - - var callbackQueue: DispatchQueue { delegateOperationQueue.underlyingQueue! } - - weak var delegate: WebSocketEngineDelegate? - - required init(request: URLRequest, sessionConfiguration: URLSessionConfiguration, callbackQueue: DispatchQueue) { - self.request = request - self.sessionConfiguration = sessionConfiguration - - delegateOperationQueue = OperationQueue(maxConcurrentOperationCount: 1) - delegateOperationQueue.underlyingQueue = callbackQueue - - super.init() - } - - deinit { - disconnect() - } - - func connect() { - urlSessionDelegateHandler = makeURLSessionDelegateHandler() - - session = URLSession( - configuration: sessionConfiguration, - delegate: urlSessionDelegateHandler, - delegateQueue: delegateOperationQueue - ) - - log.debug( - "Making Websocket upgrade request: \(request.url?.absoluteString)\n" - + "Headers:\n\(String(describing: request.allHTTPHeaderFields))\n" - + "Query items:\n\(request.queryItems.prettyPrinted)", - subsystems: .webSocket - ) - - task = session?.webSocketTask(with: request) - doRead() - task?.resume() - } - - func disconnect() { - disconnect(with: .normalClosure) - } - - func disconnect(with code: URLSessionWebSocketTask.CloseCode) { - task?.cancel(with: code, reason: nil) - session?.invalidateAndCancel() - - session = nil - task = nil - urlSessionDelegateHandler = nil - } - - func send(message: SendableEvent) { - do { - let data = try message.serializedData() - send(data: data) - } catch { - log.error("Failed sending SendableEvent", subsystems: .webSocket, error: error) - } - } - - func send(jsonMessage: Codable) { - do { - let data = try JSONEncoder().encode(jsonMessage) - send(data: data) - } catch { - log.error("Failed sending JSON message", subsystems: .webSocket, error: error) - } - } - - func sendPing() { - task?.sendPing { _ in } - } - - // MARK: - Private Helpers - - private func send(data: Data) { - let message: URLSessionWebSocketTask.Message = .data(data) - task?.send(message) { [weak self] error in - if error == nil { - log.debug( - """ - Event message sent - \(String(data: data, encoding: .utf8)) - """, subsystems: .webSocket - ) - self?.doRead() - } - } - } - - private func doRead() { - task?.receive { [weak self] result in - guard let self = self, task != nil else { - return - } - - switch result { - case let .success(message): - if case let .data(data) = message { - log.debug("Received webSocket message: \(data.debugPrettyPrintedJSON)", subsystems: .webSocket) - self.callbackQueue.async { [weak self] in - guard self?.task != nil else { return } - self?.delegate?.webSocketDidReceiveMessage(data) - } - } else if case let .string(string) = message { - let messageData = Data(string.utf8) - log.debug("Received webSocket message:\(messageData.debugPrettyPrintedJSON)", subsystems: .webSocket) - self.callbackQueue.async { [weak self] in - guard self?.task != nil else { return } - self?.delegate?.webSocketDidReceiveMessage(messageData) - } - } - self.doRead() - - case let .failure(error): - log.error("Failed while trying to receive webSocket message.", subsystems: .webSocket, error: error) - } - } - } - - private func makeURLSessionDelegateHandler() -> URLSessionDelegateHandler { - let urlSessionDelegateHandler = URLSessionDelegateHandler() - urlSessionDelegateHandler.onOpen = { [weak self] _ in - self?.performDelegateOperation { $0?.webSocketDidConnect() } - } - - urlSessionDelegateHandler.onClose = { [weak self] closeCode, reason in - let error: WebSocketEngineError? - - if let reasonData = reason, let reasonString = String(data: reasonData, encoding: .utf8) { - error = WebSocketEngineError( - reason: reasonString, - code: closeCode.rawValue, - engineError: nil - ) - log.error("WebSocket onClose", subsystems: .webSocket, error: error) - } else { - error = nil - } - - self?.performDelegateOperation { $0?.webSocketDidDisconnect(error: error) } - } - - urlSessionDelegateHandler.onCompletion = { [weak self] error in - // If we received this callback because we closed the WS connection - // intentionally, `error` param will be `nil`. - // Delegate is already informed with `didCloseWith` callback, - // so we don't need to call delegate again. - guard let error = error else { return } - - self?.callbackQueue.async { [weak self] in - let socketError = WebSocketEngineError(error: error) - log.error("WebSocket onClose", subsystems: .webSocket, error: error) - self?.delegate?.webSocketDidDisconnect(error: socketError) - } - } - - return urlSessionDelegateHandler - } - - private func performDelegateOperation( - _ operation: @Sendable @escaping (WebSocketEngineDelegate?) -> Void - ) { - callbackQueue.async { [weak delegate] in - operation(delegate) - } - } -} - -final class URLSessionDelegateHandler: NSObject, URLSessionDataDelegate, URLSessionWebSocketDelegate, @unchecked Sendable { - var onOpen: ((_ protocol: String?) -> Void)? - var onClose: ((_ code: URLSessionWebSocketTask.CloseCode, _ reason: Data?) -> Void)? - var onCompletion: ((Error?) -> Void)? - - public func urlSession( - _ session: URLSession, - webSocketTask: URLSessionWebSocketTask, - didOpenWithProtocol protocol: String? - ) { - onOpen?(`protocol`) - } - - func urlSession( - _ session: URLSession, - webSocketTask: URLSessionWebSocketTask, - didCloseWith closeCode: URLSessionWebSocketTask.CloseCode, - reason: Data? - ) { - onClose?(closeCode, reason) - } - - func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { - onCompletion?(error) - } -} diff --git a/Sources/StreamVideo/WebSockets/Client/WebSocketClient.swift b/Sources/StreamVideo/WebSockets/Client/WebSocketClient.swift deleted file mode 100644 index 9d63c11a4..000000000 --- a/Sources/StreamVideo/WebSockets/Client/WebSocketClient.swift +++ /dev/null @@ -1,351 +0,0 @@ -// -// Copyright © 2026 Stream.io Inc. All rights reserved. -// - -import Combine -import Foundation - -class WebSocketClient: @unchecked Sendable { - /// The notification center `WebSocketClient` uses to send notifications about incoming events. - let eventNotificationCenter: EventNotificationCenter - - /// The batch of events received via the web-socket that wait to be processed. - private(set) lazy var eventsBatcher = environment.eventBatcherBuilder { [weak self] events, completion in - guard let self else { return } - events.forEach { [eventSubject] in eventSubject.send($0) } - eventNotificationCenter.process(events, completion: completion) - } - - /// The current state the web socket connection. - @Atomic private(set) var connectionState: WebSocketConnectionState = .initialized { - didSet { - pingController.connectionStateDidChange(connectionState) - - guard connectionState != oldValue else { return } - - log.info("Web socket connection state changed: \(connectionState)", subsystems: .webSocket) - - connectionSubject.send(connectionState) - connectionStateDelegate?.webSocketClient(self, didUpdateConnectionState: connectionState) - } - } - - let connectionSubject = PassthroughSubject() - let eventSubject = PassthroughSubject() - - weak var connectionStateDelegate: ConnectionStateDelegate? - - var connectURL: URL - - var requiresAuth: Bool - - /// The decoder used to decode incoming events - private let eventDecoder: AnyEventDecoder - - /// The web socket engine used to make the actual WS connection - private(set) var engine: WebSocketEngine? - - /// The queue on which web socket engine methods are called - private let engineQueue: DispatchQueue = .init(label: "io.getStream.video.core.web_socket_engine_queue", qos: .userInitiated) - - /// The session config used for the web socket engine - private let sessionConfiguration: URLSessionConfiguration - - /// An object containing external dependencies of `WebSocketClient` - private let environment: Environment - - private let webSocketClientType: WebSocketClientType - - let pingController: WebSocketPingController - - private func createEngineIfNeeded(for connectURL: URL) -> WebSocketEngine { - let request = URLRequest(url: connectURL) - - if let existedEngine = engine, existedEngine.request == request { - return existedEngine - } - - let engine = environment.createEngine(request, sessionConfiguration, engineQueue) - engine.delegate = self - return engine - } - - var onWSConnectionEstablished: (() -> Void)? - var onConnected: (() -> Void)? - - init( - sessionConfiguration: URLSessionConfiguration, - eventDecoder: AnyEventDecoder, - eventNotificationCenter: EventNotificationCenter, - webSocketClientType: WebSocketClientType, - environment: Environment = .init(), - connectURL: URL, - requiresAuth: Bool = true - ) { - self.environment = environment - self.sessionConfiguration = sessionConfiguration - self.webSocketClientType = webSocketClientType - self.eventDecoder = eventDecoder - self.connectURL = connectURL - self.eventNotificationCenter = eventNotificationCenter - self.requiresAuth = requiresAuth - pingController = environment.createPingController( - environment.timerType, - engineQueue, - webSocketClientType - ) - - pingController.delegate = self - } - - /// Connects the web connect. - /// - /// Calling this method has no effect is the web socket is already connected, or is in the connecting phase. - func connect() { - switch connectionState { - // Calling connect in the following states has no effect - case .connecting, .authenticating, .connected(healthCheckInfo: _): - return - default: break - } - - engine = createEngineIfNeeded(for: connectURL) - - connectionState = .connecting - - engineQueue.async { [weak engine] in - engine?.connect() - } - } - - /// Disconnects the web socket. - /// - /// Calling this function has no effect, if the connection is in an inactive state. - /// - Parameter source: Additional information about the source of the disconnection. Default value is `.userInitiated`. - func disconnect( - code: URLSessionWebSocketTask.CloseCode = .normalClosure, - source: WebSocketConnectionState.DisconnectionSource = .userInitiated, - completion: @Sendable @escaping () -> Void - ) { - connectionState = .disconnecting(source: source) - engineQueue.async { [engine, eventsBatcher] in - engine?.disconnect(with: code) - - eventsBatcher.processImmediately(completion: completion) - } - } - - func disconnect(source: WebSocketConnectionState.DisconnectionSource = .userInitiated) async { - await withCheckedContinuation { [weak self] continuation in - guard let self else { - continuation.resume() - return - } - disconnect { - continuation.resume() - } - } - } -} - -protocol ConnectionStateDelegate: AnyObject { - func webSocketClient(_ client: WebSocketClient, didUpdateConnectionState state: WebSocketConnectionState) -} - -extension WebSocketClient { - /// An object encapsulating all dependencies of `WebSocketClient`. - struct Environment { - typealias CreatePingController = ( - _ timerType: Timer.Type, - _ timerQueue: DispatchQueue, - _ webSocketClientType: WebSocketClientType - ) -> WebSocketPingController - - typealias CreateEngine = ( - _ request: URLRequest, - _ sessionConfiguration: URLSessionConfiguration, - _ callbackQueue: DispatchQueue - ) -> WebSocketEngine - - var timerType: Timer.Type = DefaultTimer.self - - var createPingController: CreatePingController = WebSocketPingController.init - - var createEngine: CreateEngine = { - URLSessionWebSocketEngine(request: $0, sessionConfiguration: $1, callbackQueue: $2) - } - - var httpClientBuilder: () -> HTTPClient = { - URLSessionClient( - urlSession: StreamVideo.Environment.makeURLSession() - ) - } - - var eventBatcherBuilder: ( - _ handler: @Sendable @escaping ([WrappedEvent], @Sendable @escaping () -> Void) -> Void - ) -> EventBatcher = { - Batcher(period: 0.0, handler: $0) - } - } -} - -// MARK: - Web Socket Delegate - -extension WebSocketClient: WebSocketEngineDelegate { - func webSocketDidConnect() { - log.debug("Web socket connection established", subsystems: .webSocket) - connectionState = .authenticating - onWSConnectionEstablished?() - } - - func webSocketDidReceiveMessage(_ data: Data) { - var event: WrappedEvent - - do { - event = try eventDecoder.decode(from: data) - } catch { - do { - let apiError = try JSONDecoder.default.decode(APIErrorContainer.self, from: data).error - log.error("web socket error \(apiError.message)", subsystems: .webSocket, error: apiError) - } catch let decodingError { - log.warning( - """ - Decoding websocket payload failed - payload: \(String(data: data, encoding: .utf8) ?? "-") - - Error: \(decodingError) - """, - subsystems: .webSocket - ) - } - return - } - - if let error = event.error() { - log.error("Received an error webSocket event.", subsystems: .webSocket, error: error) - connectionState = .disconnecting(source: .serverInitiated(error: ClientError(with: error))) - return - } else { - log.info("Received webSocket event \(event.name).", subsystems: .webSocket) - } - - // healthcheck events are not passed to batcher - if let info = event.healthcheck() { - handle(healthcheck: event, info: info) - return - } - - eventsBatcher.append(event) - } - - func webSocketDidDisconnect(error engineError: WebSocketEngineError?) { - switch connectionState { - case .connecting, .authenticating, .connected: - let serverError = engineError.map { ClientError.WebSocket(with: $0) } - - connectionState = .disconnected(source: .serverInitiated(error: serverError)) - - case let .disconnecting(source): - connectionState = .disconnected(source: source) - - case .initialized, .disconnected: - log.error( - "Web socket can not be disconnected when in \(connectionState) state", - subsystems: .webSocket, - error: engineError - ) - } - } - - private func handle(healthcheck: WrappedEvent, info: HealthCheckInfo) { - log.debug("Handling healthcheck", subsystems: .webSocket) - - if connectionState == .authenticating { - connectionState = .connected(healthCheckInfo: info) - onConnected?() - } - - // We send the healthcheck to the eventSubject so that observers - // (e.g. SFUEventAdapter) get updated. - eventSubject.send(healthcheck) - eventNotificationCenter.process(healthcheck, postNotification: false) { [weak self] in - self?.engineQueue.async { [weak self] in - self?.pingController.pongReceived() - self?.connectionState = .connected(healthCheckInfo: info) - } - } - } -} - -// MARK: - Ping Controller Delegate - -extension WebSocketClient: WebSocketPingControllerDelegate { - - func sendPing(healthCheckEvent: SendableEvent) { - engineQueue.async { [weak engine] in - if case .connected(healthCheckInfo: _) = self.connectionState { - engine?.send(message: healthCheckEvent) - } - } - } - - func sendPing() { - engine?.sendPing() - } - - func disconnectOnNoPongReceived() { - log.debug("disconnecting from \(connectURL)", subsystems: .webSocket) - let closeCode: URLSessionWebSocketTask.CloseCode - switch webSocketClientType { - case .coordinator: - closeCode = .normalClosure - case .sfu: - closeCode = .init(rawValue: 4001)! - } - disconnect(code: closeCode, source: .noPongReceived) { - log.debug("Websocket is disconnected because of no pong received", subsystems: .webSocket) - } - } -} - -enum WebSocketClientType { - case coordinator - case sfu -} - -// MARK: - Notifications - -extension Notification.Name { - /// The name of the notification posted when a new event is published/ - static let NewEventReceived = Notification.Name("io.getStream.video.core.new_event_received") -} - -extension Notification { - private static let eventKey = "io.getStream.video.core.event_key" - - init(newEventReceived event: Event, sender: Any) { - self.init(name: .NewEventReceived, object: sender, userInfo: [Self.eventKey: event]) - } - - var event: WrappedEvent? { - userInfo?[Self.eventKey] as? WrappedEvent - } -} - -// MARK: - Test helpers - -#if TESTS -extension WebSocketClient { - /// Simulates connection status change - func simulateConnectionStatus(_ status: WebSocketConnectionState) { - connectionState = status - } -} -#endif - -extension ClientError { - public class WebSocket: ClientError, @unchecked Sendable {} -} - -struct WSDisconnected: Event {} -struct WSConnected: Event {} diff --git a/Sources/StreamVideo/WebSockets/Client/WebSocketEngine.swift b/Sources/StreamVideo/WebSockets/Client/WebSocketEngine.swift deleted file mode 100644 index 994027604..000000000 --- a/Sources/StreamVideo/WebSockets/Client/WebSocketEngine.swift +++ /dev/null @@ -1,54 +0,0 @@ -// -// Copyright © 2026 Stream.io Inc. All rights reserved. -// - -import Foundation - -protocol WebSocketEngine: AnyObject, Sendable { - var request: URLRequest { get } - var callbackQueue: DispatchQueue { get } - var delegate: WebSocketEngineDelegate? { get set } - - init(request: URLRequest, sessionConfiguration: URLSessionConfiguration, callbackQueue: DispatchQueue) - - func connect() - func disconnect() - func disconnect(with code: URLSessionWebSocketTask.CloseCode) - func send(message: SendableEvent) - func send(jsonMessage: Codable) - func sendPing() -} - -protocol WebSocketEngineDelegate: AnyObject, Sendable { - func webSocketDidConnect() - func webSocketDidDisconnect(error: WebSocketEngineError?) - func webSocketDidReceiveMessage(_ data: Data) -} - -struct WebSocketEngineError: Error { - static let stopErrorCode = 1000 - - let reason: String - let code: Int - let engineError: Error? - - var localizedDescription: String { reason } -} - -extension WebSocketEngineError { - init(error: Error?) { - if let error = error { - self.init( - reason: error.localizedDescription, - code: (error as NSError).code, - engineError: error - ) - } else { - self.init( - reason: "Unknown", - code: 0, - engineError: nil - ) - } - } -} diff --git a/Sources/StreamVideo/WebSockets/Client/WebSocketPingController.swift b/Sources/StreamVideo/WebSockets/Client/WebSocketPingController.swift deleted file mode 100644 index c4116d02c..000000000 --- a/Sources/StreamVideo/WebSockets/Client/WebSocketPingController.swift +++ /dev/null @@ -1,127 +0,0 @@ -// -// Copyright © 2026 Stream.io Inc. All rights reserved. -// - -import Foundation - -/// A delegate to control `WebSocketClient` connection by `WebSocketPingController`. -protocol WebSocketPingControllerDelegate: AnyObject { - - /// `WebSocketPingController` will call this function periodically to keep a connection alive. - func sendPing(healthCheckEvent: SendableEvent) - - /// Regular ping, without sending any data. - func sendPing() - - /// `WebSocketPingController` will call this function to force disconnect `WebSocketClient`. - func disconnectOnNoPongReceived() -} - -struct HealthCheckInfo: Equatable { - var coordinatorHealthCheck: HealthCheckEvent? - var sfuHealthCheck: Stream_Video_Sfu_Event_HealthCheckResponse? -} - -protocol HealthCheck: Event, Equatable {} - -/// The controller manages ping and pong timers. It sends ping periodically to keep a web socket connection alive. -/// After ping is sent, a pong waiting timer is started, and if pong does not come, a forced disconnect is called. -class WebSocketPingController { - /// The time interval to ping connection to keep it alive. - /// - Note: - /// Updated to 5 seconds based on https://www.notion.so/stream-wiki/Improved-Reconnects-and-ICE-connection-handling-2186a5d7f9f680c29236c2c37cfa11a3?source=copy_link#2186a5d7f9f68043968df9453ef5fa88 - static let pingTimeInterval: TimeInterval = 5 - /// The time interval for pong timeout. - static let pongTimeoutTimeInterval: TimeInterval = 3 - - private let timerType: Timer.Type - private let timerQueue: DispatchQueue - - /// The timer used for scheduling `ping` calls - private var pingTimerControl: RepeatingTimerControl? - - /// The pong timeout timer. - private var pongTimeoutTimer: TimerControl? - - private let webSocketClientType: WebSocketClientType - - /// A delegate to control `WebSocketClient` connection by `WebSocketPingController`. - weak var delegate: WebSocketPingControllerDelegate? - - deinit { - cancelPongTimeoutTimer() - } - - /// Creates a ping controller. - /// - Parameters: - /// - timerType: a timer type. - /// - timerQueue: a timer dispatch queue. - init( - timerType: Timer.Type, - timerQueue: DispatchQueue, - webSocketClientType: WebSocketClientType - ) { - self.timerType = timerType - self.timerQueue = timerQueue - self.webSocketClientType = webSocketClientType - } - - /// `WebSocketClient` should call this when the connection state did change. - func connectionStateDidChange(_ connectionState: WebSocketConnectionState) { - guard delegate != nil else { return } - - cancelPongTimeoutTimer() - schedulePingTimerIfNeeded() - - if connectionState.isConnected { - log.info("Resume WebSocket Ping timer", subsystems: .httpRequests) - pingTimerControl?.resume() - } else { - pingTimerControl?.suspend() - } - } - - // MARK: - Ping - - private func sendPing() { - schedulePongTimeoutTimer() - - log.info("WebSocket Ping", subsystems: .webSocket) - if webSocketClientType == .coordinator { - delegate?.sendPing() - } else { - var sfuRequest = Stream_Video_Sfu_Event_SfuRequest() - let healthCheckEvent = Stream_Video_Sfu_Event_HealthCheckRequest() - sfuRequest.healthCheckRequest = healthCheckEvent - delegate?.sendPing(healthCheckEvent: sfuRequest) - } - } - - func pongReceived() { - log.info("WebSocket Pong", subsystems: .webSocket) - cancelPongTimeoutTimer() - } - - // MARK: Timers - - private func schedulePingTimerIfNeeded() { - guard pingTimerControl == nil else { return } - pingTimerControl = timerType.scheduleRepeating(timeInterval: Self.pingTimeInterval, queue: timerQueue) { [weak self] in - self?.sendPing() - } - } - - private func schedulePongTimeoutTimer() { - cancelPongTimeoutTimer() - // Start pong timeout timer. - pongTimeoutTimer = timerType.schedule(timeInterval: Self.pongTimeoutTimeInterval, queue: timerQueue) { [weak self] in - log.info("WebSocket Pong timeout. Reconnect", subsystems: .webSocket) - self?.delegate?.disconnectOnNoPongReceived() - } - } - - private func cancelPongTimeoutTimer() { - pongTimeoutTimer?.cancel() - pongTimeoutTimer = nil - } -} diff --git a/Sources/StreamVideo/WebSockets/Events/Event.swift b/Sources/StreamVideo/WebSockets/Events/Event.swift index a72f4e70e..032cd43ae 100644 --- a/Sources/StreamVideo/WebSockets/Events/Event.swift +++ b/Sources/StreamVideo/WebSockets/Events/Event.swift @@ -3,17 +3,9 @@ // import Foundation - -/// An `Event` object representing an event in the chat system. -public protocol Event: Sendable {} - -public protocol SendableEvent: Event, ProtoModel, ReflectiveStringConvertible {} +import StreamCore extension Event { - var name: String { - String(describing: Self.self) - } - func unwrap() -> VideoEvent? { if let unwrapped = self as? VideoEvent { return unwrapped @@ -37,31 +29,23 @@ extension Event { } } -/// An internal object that we use to wrap the kind of events that are handled by WS: SFU and coordinator events internal enum WrappedEvent: Event, Sendable, CustomStringConvertible { case internalEvent(Event) case coordinatorEvent(VideoEvent) - case sfuEvent(Stream_Video_Sfu_Event_SfuEvent.OneOf_EventPayload) func healthcheck() -> HealthCheckInfo? { switch self { case let .coordinatorEvent(event): if case let .typeHealthCheckEvent(healthCheckEvent) = event { - return HealthCheckInfo(coordinatorHealthCheck: healthCheckEvent) + return HealthCheckInfo( + connectionId: healthCheckEvent.connectionId + ) } if case let .typeConnectedEvent(connectedEvent) = event { return HealthCheckInfo( - coordinatorHealthCheck: .init( - cid: nil, - connectionId: connectedEvent.connectionId, - createdAt: connectedEvent.createdAt - ) + connectionId: connectedEvent.connectionId ) } - case let .sfuEvent(event): - if case let .healthCheckResponse(healthCheckEvent) = event { - return HealthCheckInfo(sfuHealthCheck: healthCheckEvent) - } case .internalEvent: break } @@ -74,11 +58,6 @@ internal enum WrappedEvent: Event, Sendable, CustomStringConvertible { if case let .typeConnectionErrorEvent(errorEvent) = event { return errorEvent.error } - case let .sfuEvent(event): - if case let .error(errorEvent) = event { - return errorEvent.error - } - return nil case .internalEvent: break } @@ -89,8 +68,6 @@ internal enum WrappedEvent: Event, Sendable, CustomStringConvertible { switch self { case let .coordinatorEvent(event): return "coordinator: \(event.type)" - case let .sfuEvent(event): - return "sfu: \(event.name)" case let .internalEvent(event): return "internal: \(event.name)" } @@ -100,8 +77,6 @@ internal enum WrappedEvent: Event, Sendable, CustomStringConvertible { switch self { case let .coordinatorEvent(event): return "coordinator: \(event)" - case let .sfuEvent(event): - return "sfu: \(event)" case let .internalEvent(event): return "internal: \(event)" } diff --git a/Sources/StreamVideo/WebSockets/Events/EventBatcher.swift b/Sources/StreamVideo/WebSockets/Events/EventBatcher.swift deleted file mode 100644 index 464b11271..000000000 --- a/Sources/StreamVideo/WebSockets/Events/EventBatcher.swift +++ /dev/null @@ -1,80 +0,0 @@ -// -// Copyright © 2026 Stream.io Inc. All rights reserved. -// - -import Foundation - -/// The type that does events batching. -protocol EventBatcher: Sendable { - typealias Batch = [WrappedEvent] - typealias BatchHandler = @Sendable (_ batch: Batch, _ completion: @Sendable @escaping () -> Void) -> Void - - /// The current batch of events. - var currentBatch: Batch { get } - - /// Creates new batch processor. - init(period: TimeInterval, timerType: Timer.Type, handler: @escaping BatchHandler) - - /// Adds the item to the current batch of events. If it's the first event also schedules batch processing - /// that will happen when `period` has passed. - /// - /// - Parameter event: The event to add to the current batch. - func append(_ event: WrappedEvent) - - /// Ignores `period` and passes the current batch of events to handler as soon as possible. - func processImmediately(completion: @Sendable @escaping () -> Void) -} - -extension Batcher: EventBatcher, @unchecked Sendable where Item == WrappedEvent {} - -final class Batcher { - /// The batching period. If the item is added sonner then `period` has passed after the first item they will get into the same batch. - private let period: TimeInterval - /// The time used to create timers. - private let timerType: Timer.Type - /// The timer that calls `processor` when fired. - private var batchProcessingTimer: TimerControl? - /// The closure which processes the batch. - private let handler: @Sendable (_ batch: [Item], _ completion: @Sendable @escaping () -> Void) -> Void - /// The serial queue where item appends and batch processing is happening on. - private let queue = DispatchQueue(label: "io.getstream.Batch.\(Item.self)") - /// The current batch of items. - private(set) var currentBatch: [Item] = [] - - init( - period: TimeInterval, - timerType: Timer.Type = DefaultTimer.self, - handler: @Sendable @escaping (_ batch: [Item], _ completion: @Sendable @escaping () -> Void) -> Void - ) { - self.period = max(period, 0) - self.timerType = timerType - self.handler = handler - } - - func append(_ item: Item) { - timerType.schedule(timeInterval: 0, queue: queue) { [weak self] in - self?.currentBatch.append(item) - - guard let self = self, self.batchProcessingTimer == nil else { return } - - self.batchProcessingTimer = self.timerType.schedule( - timeInterval: self.period, - queue: self.queue, - onFire: { self.process() } - ) - } - } - - func processImmediately(completion: @Sendable @escaping () -> Void) { - timerType.schedule(timeInterval: 0, queue: queue) { [weak self] in - self?.process(completion: completion) - } - } - - private func process(completion: (@Sendable () -> Void)? = nil) { - handler(currentBatch) { completion?() } - currentBatch.removeAll() - batchProcessingTimer?.cancel() - batchProcessingTimer = nil - } -} diff --git a/Sources/StreamVideo/WebSockets/Events/EventMiddleware.swift b/Sources/StreamVideo/WebSockets/Events/EventMiddleware.swift deleted file mode 100644 index 2da4c4fbb..000000000 --- a/Sources/StreamVideo/WebSockets/Events/EventMiddleware.swift +++ /dev/null @@ -1,34 +0,0 @@ -// -// Copyright © 2026 Stream.io Inc. All rights reserved. -// - -import Foundation - -/// An object used to pre-process incoming `Event`. -protocol EventMiddleware { - /// Processes the incoming event and returns `nil` if it was consumed (no further processing is needed). - /// - /// - Parameters: - /// - event: The incoming `Event`. - /// - Returns: The original `event` passed via params OR `nil` if the incoming event was consumed by the middleware. - func handle(event: WrappedEvent) -> WrappedEvent? -} - -extension Array where Element == EventMiddleware { - /// Evaluates an array of `EventMiddleware`s in the order they're specified in the array. It's not guaranteed that - /// all middlewares are called. If a middleware returns `nil`, no middlewares down in the chain are called. - /// - /// - Parameters: - /// - event: The event to be pre-processed. - /// - Returns: The processed event. It will return `nil` if the event was consumed by some middleware. - func process(event: WrappedEvent) -> WrappedEvent? { - var output: WrappedEvent? = event - - for middleware in self { - guard let input = output else { break } - output = middleware.handle(event: input) - } - - return output - } -} diff --git a/Sources/StreamVideo/WebSockets/Events/EventNotificationCenter.swift b/Sources/StreamVideo/WebSockets/Events/EventNotificationCenter.swift deleted file mode 100644 index e13d5a376..000000000 --- a/Sources/StreamVideo/WebSockets/Events/EventNotificationCenter.swift +++ /dev/null @@ -1,55 +0,0 @@ -// -// Copyright © 2026 Stream.io Inc. All rights reserved. -// - -import Foundation - -/// The type is designed to pre-process some incoming `Event` via middlewares before being published -class EventNotificationCenter: NotificationCenter, @unchecked Sendable { - private(set) var middlewares: [EventMiddleware] = [] - - var eventPostingQueue = DispatchQueue(label: "io.getstream.event-notification-center", qos: .default) - - func add(middlewares: [EventMiddleware]) { - self.middlewares.append(contentsOf: middlewares) - } - - func add(middleware: EventMiddleware) { - middlewares.append(middleware) - } - - func process( - _ events: [WrappedEvent], - postNotifications: Bool = true, - completion: (@Sendable () -> Void)? = nil - ) { - log.debug( - "Processing webSocket events: \(events)", - subsystems: .webSocket - ) - - let eventsToPost = events.compactMap { - self.middlewares.process(event: $0) - } - - guard postNotifications else { - completion?() - return - } - - eventPostingQueue.async { - eventsToPost.forEach { self.post(Notification(newEventReceived: $0, sender: self)) } - completion?() - } - } -} - -extension EventNotificationCenter { - func process( - _ event: WrappedEvent, - postNotification: Bool = true, - completion: (@Sendable () -> Void)? = nil - ) { - process([event], postNotifications: postNotification, completion: completion) - } -} diff --git a/Sources/StreamVideo/WebSockets/Events/JsonEventDecoder.swift b/Sources/StreamVideo/WebSockets/Events/JsonEventDecoder.swift index c164e46cf..69d99bd25 100644 --- a/Sources/StreamVideo/WebSockets/Events/JsonEventDecoder.swift +++ b/Sources/StreamVideo/WebSockets/Events/JsonEventDecoder.swift @@ -3,11 +3,12 @@ // import Foundation +import StreamCore struct JsonEventDecoder: AnyEventDecoder { - func decode(from data: Data) throws -> WrappedEvent { - let event = try StreamJSONDecoder.default.decode(VideoEvent.self, from: data) - return .coordinatorEvent(event) + func decode(from data: Data) throws -> Event { + let event = try JSONDecoder.streamCore.decode(VideoEvent.self, from: data) + return WrappedEvent.coordinatorEvent(event) } } @@ -24,24 +25,3 @@ extension UserResponse { ) } } - -extension ClientError { - public class UnsupportedEventType: ClientError, @unchecked Sendable { - override public var localizedDescription: String { "The incoming event type is not supported. Ignoring." } - } - - public class EventDecoding: ClientError, @unchecked Sendable { - override init(_ message: String, _ file: StaticString = #fileID, _ line: UInt = #line) { - super.init(message, file, line) - } - - init(missingValue: String, for type: T.Type, _ file: StaticString = #fileID, _ line: UInt = #line) { - super.init("`\(missingValue)` field can't be `nil` for the `\(type)` event.", file, line) - } - } -} - -/// A type-erased wrapper protocol for `EventDecoder`. -protocol AnyEventDecoder { - func decode(from: Data) throws -> WrappedEvent -} diff --git a/Sources/StreamVideo/WebSockets/Events/StreamJsonDecoder.swift b/Sources/StreamVideo/WebSockets/Events/StreamJsonDecoder.swift deleted file mode 100644 index e2d514dca..000000000 --- a/Sources/StreamVideo/WebSockets/Events/StreamJsonDecoder.swift +++ /dev/null @@ -1,181 +0,0 @@ -// -// Copyright © 2026 Stream.io Inc. All rights reserved. -// - -import Foundation - -// MARK: - JSONDecoder Stream - -final class StreamJSONDecoder: JSONDecoder, @unchecked Sendable { - let iso8601formatter: ISO8601DateFormatter - let dateCache: NSCache - - override convenience init() { - let iso8601formatter = ISO8601DateFormatter() - iso8601formatter.formatOptions = [.withFractionalSeconds, .withInternetDateTime] - - let dateCache = NSCache() - dateCache.countLimit = 5000 // We cache at most 5000 dates, which gives good enough performance - - self.init(dateFormatter: iso8601formatter, dateCache: dateCache) - } - - init(dateFormatter: ISO8601DateFormatter, dateCache: NSCache) { - iso8601formatter = dateFormatter - self.dateCache = dateCache - - super.init() - - dateDecodingStrategy = .custom { [weak self] decoder throws -> Date in - let container = try decoder.singleValueContainer() - let dateString: String = try container.decode(String.self) - - if let date = self?.dateCache.object(forKey: dateString as NSString) { - return date.bridgeDate - } - - if let date = self?.iso8601formatter.date(from: dateString) { - self?.dateCache.setObject(date.bridgeDate, forKey: dateString as NSString) - return date - } - - if let date = DateFormatter.Stream.rfc3339Date(from: dateString) { - self?.dateCache.setObject(date.bridgeDate, forKey: dateString as NSString) - return date - } - - // Fail - throw DecodingError.dataCorruptedError(in: container, debugDescription: "Invalid date: \(dateString)") - } - } -} - -extension JSONDecoder { - /// A default `JSONDecoder`. - static let `default`: JSONDecoder = stream - - /// A Stream Chat JSON decoder. - static let stream: StreamJSONDecoder = { - StreamJSONDecoder() - }() -} - -// MARK: - JSONEncoder Stream - -extension JSONEncoder { - /// A default `JSONEncoder`. - static let `default`: JSONEncoder = stream - /// A default gzip `JSONEncoder`. - static let defaultGzip: JSONEncoder = streamGzip - - /// A Stream Chat JSON encoder. - static let stream: JSONEncoder = { - let encoder = JSONEncoder() - encoder.dateEncodingStrategy = .stream - return encoder - }() - - /// A Stream Chat JSON encoder with a gzipped content. - static let streamGzip: JSONEncoder = { - let encoder = JSONEncoder() - encoder.dataEncodingStrategy = .gzip - encoder.dateEncodingStrategy = .stream - return encoder - }() -} - -extension JSONEncoder.DataEncodingStrategy { - // Gzip data encoding. - static var gzip: JSONEncoder.DataEncodingStrategy { - .custom { data, encoder throws in - var container = encoder.singleValueContainer() - let gzippedData = try data.gzipped() - try container.encode(gzippedData) - } - } -} - -extension JSONEncoder.DateEncodingStrategy { - /// A Stream encoding for the custom ISO8601 date. - static var stream: JSONEncoder.DateEncodingStrategy { - .custom { date, encoder throws in - var container = encoder.singleValueContainer() - try container.encode(DateFormatter.Stream.rfc3339DateString(from: date)) - } - } -} - -// MARK: - Date Formatter Helper - -extension DateFormatter { - /// Stream Chat date formatters. - enum Stream { - // Creates and returns a date object from the specified RFC3339 formatted string representation. - /// - /// - Parameter string: The RFC3339 formatted string representation of a date. - /// - Returns: A date object, or nil if no valid date was found. - static func rfc3339Date(from string: String) -> Date? { - let RFC3339TimezoneWrapper = "Z" - let uppercaseString = string.uppercased() - let removedTimezoneWrapperString = uppercaseString.replacingOccurrences(of: RFC3339TimezoneWrapper, with: "-0000") - return gmtDateFormatters.lazy.compactMap { $0.date(from: removedTimezoneWrapperString) }.first - } - - /// Creates and returns an RFC 3339 formatted string representation of the specified date. - /// - /// - Parameter date: The date to be represented. - /// - Returns: A user-readable string representing the date. - static func rfc3339DateString(from date: Date) -> String? { - let nanosecondsInMillisecond = 1_000_000 - - var gmtCalendar = Calendar(identifier: .iso8601) - if let zeroTimezone = TimeZone(secondsFromGMT: 0) { - gmtCalendar.timeZone = zeroTimezone - } - - let components = gmtCalendar.dateComponents([.nanosecond], from: date) - // If nanoseconds is more that 1 millisecond, use format with fractional seconds - guard let nanoseconds = components.nanosecond, - nanoseconds >= nanosecondsInMillisecond - else { - return dateFormatterWithoutFractional.string(from: date) - } - - return dateFormatterWithFractional.string(from: date) - } - - // Formats according to samples - // 2000-12-19T16:39:57-0800 - // 1934-01-01T12:00:27.87+0020 - // 1989-01-01T12:00:27 - private static let gmtDateFormatters: [DateFormatter] = [ - "yyyy'-'MM'-'dd'T'HH':'mm':'ssZZZ", - "yyyy'-'MM'-'dd'T'HH':'mm':'ss.SSSZZZ", - "yyyy'-'MM'-'dd'T'HH':'mm':'ss" - ].map(makeDateFormatter) - - private static let dateFormatterWithoutFractional = makeDateFormatter(dateFormat: "yyyy-MM-dd'T'HH:mm:ssZZZZZ") - private static let dateFormatterWithFractional = makeDateFormatter(dateFormat: "yyyy-MM-dd'T'HH:mm:ss.SSSXXXXX") - - private static func makeDateFormatter(dateFormat: String) -> DateFormatter { - let formatter = DateFormatter() - formatter.locale = Locale(identifier: "en_US_POSIX") - formatter.timeZone = TimeZone(secondsFromGMT: 0) - formatter.dateFormat = dateFormat - return formatter - } - } -} - -typealias DBDate = NSDate -extension DBDate { - var bridgeDate: Date { - Date(timeIntervalSince1970: timeIntervalSince1970) - } -} - -extension Date { - var bridgeDate: DBDate { - DBDate(timeIntervalSince1970: timeIntervalSince1970) - } -} diff --git a/Sources/StreamVideo/WebSockets/Events/VideoEvent+ReflectiveStringConvertible.swift b/Sources/StreamVideo/WebSockets/Events/VideoEvent+ReflectiveStringConvertible.swift index 04bee3c39..ee79e935b 100644 --- a/Sources/StreamVideo/WebSockets/Events/VideoEvent+ReflectiveStringConvertible.swift +++ b/Sources/StreamVideo/WebSockets/Events/VideoEvent+ReflectiveStringConvertible.swift @@ -3,6 +3,7 @@ // import Foundation +import StreamCore extension VideoEvent: ReflectiveStringConvertible {} extension AppUpdatedEvent: ReflectiveStringConvertible {} diff --git a/Sources/StreamVideo/WebSockets/Events/WSEventsMiddleware.swift b/Sources/StreamVideo/WebSockets/Events/WSEventsMiddleware.swift index 764cec813..f52966054 100644 --- a/Sources/StreamVideo/WebSockets/Events/WSEventsMiddleware.swift +++ b/Sources/StreamVideo/WebSockets/Events/WSEventsMiddleware.swift @@ -3,13 +3,16 @@ // import Foundation +import StreamCore final class WSEventsMiddleware: EventMiddleware, @unchecked Sendable { private var subscribers = NSHashTable.weakObjects() private let processingQueue = OperationQueue(maxConcurrentOperationCount: 1) - func handle(event: WrappedEvent) -> WrappedEvent? { + func handle(event: Event) -> Event? { + guard let event = event as? WrappedEvent else { return event } + processingQueue.addTaskOperation { [weak self] in guard let self else { return } let allObjects = subscribers.allObjects diff --git a/Sources/StreamVideoSwiftUI/CallViewModel.swift b/Sources/StreamVideoSwiftUI/CallViewModel.swift index de7910992..836f0f609 100644 --- a/Sources/StreamVideoSwiftUI/CallViewModel.swift +++ b/Sources/StreamVideoSwiftUI/CallViewModel.swift @@ -121,10 +121,10 @@ open class CallViewModel: ObservableObject { public var error: Error? { didSet { errorAlertShown = error != nil - if let error = error as? APIError { - toast = Toast(style: .error, message: error.message) - } else if let error { - toast = Toast(style: .error, message: error.localizedDescription) + if let error { + let message = ClientError(with: error).apiError?.message + ?? error.localizedDescription + toast = Toast(style: .error, message: message) } else { toast = nil } diff --git a/StreamVideo.xcodeproj/project.pbxproj b/StreamVideo.xcodeproj/project.pbxproj index dc83ea784..93ee2c6c6 100644 --- a/StreamVideo.xcodeproj/project.pbxproj +++ b/StreamVideo.xcodeproj/project.pbxproj @@ -58,6 +58,12 @@ 84F73848287C196B00A363F4 /* StreamVideo.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 84F737ED287C13AC00A363F4 /* StreamVideo.framework */; }; 84F7384D287C198500A363F4 /* StreamVideo.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 84F737ED287C13AC00A363F4 /* StreamVideo.framework */; }; 84F7384E287C198500A363F4 /* StreamVideo.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 84F737ED287C13AC00A363F4 /* StreamVideo.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + C0DE0C6E00000000000000C3 /* StreamCore in Frameworks */ = {isa = PBXBuildFile; productRef = C0DE0C6E00000000000000B2 /* StreamCore */; }; + C0DE0C6E00000000000000C4 /* StreamCore in Frameworks */ = {isa = PBXBuildFile; productRef = C0DE0C6E00000000000000B3 /* StreamCore */; }; + C0DE0C6E00000000000000C5 /* StreamCore in Frameworks */ = {isa = PBXBuildFile; productRef = C0DE0C6E00000000000000B4 /* StreamCore */; }; + C0DE0C6E00000000000000C6 /* StreamCore in Frameworks */ = {isa = PBXBuildFile; productRef = C0DE0C6E00000000000000B5 /* StreamCore */; }; + C0DE0C6E00000000000000C7 /* StreamCore in Frameworks */ = {isa = PBXBuildFile; productRef = C0DE0C6E00000000000000B6 /* StreamCore */; }; + C0DE0C6E00000000000000C8 /* StreamCore in Frameworks */ = {isa = PBXBuildFile; productRef = C0DE0C6E00000000000000B7 /* StreamCore */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -265,8 +271,6 @@ membershipExceptions = ( Mock/CallController_Mock.swift, Mock/CallParticipant_Mock.swift, - Mock/EventBatcher_Mock.swift, - Mock/EventDecoder_Mock.swift, Mock/HTTPClient_Mock.swift, Mock/MockAppStateAdapter.swift, Mock/MockCall.swift, @@ -285,9 +289,6 @@ Mock/StreamVideo_Mock.swift, Mock/StreamVideoCallSession_Mock.swift, "Mock/VideoConfig+Dummy.swift", - Mock/WebSocketClientEnvironment_Mock.swift, - Mock/WebSocketEngine_Mock.swift, - Mock/WebSocketPingController_Mock.swift, StreamVideoTestCase.swift, TestUtils/AssertAsync.swift, TestUtils/AssertDelay.swift, @@ -356,17 +357,12 @@ membershipExceptions = ( Mock/CallController_Mock.swift, Mock/CallParticipant_Mock.swift, - Mock/EventBatcher_Mock.swift, - Mock/EventDecoder_Mock.swift, Mock/HTTPClient_Mock.swift, Mock/MockDefaultAPIEndpoints.swift, Mock/MockFunc.swift, Mock/MockResponseBuilder.swift, Mock/StreamVideo_Mock.swift, "Mock/VideoConfig+Dummy.swift", - Mock/WebSocketClientEnvironment_Mock.swift, - Mock/WebSocketEngine_Mock.swift, - Mock/WebSocketPingController_Mock.swift, TestUtils/AssertAsync.swift, TestUtils/EquatableEvent.swift, TestUtils/Mockable.swift, @@ -1092,6 +1088,7 @@ files = ( 84BE8A5628BE314000B34D2F /* SwiftProtobuf in Frameworks */, 40B8FFA72EC393A80061E3F6 /* StreamWebRTC in Frameworks */, + C0DE0C6E00000000000000C3 /* StreamCore in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1101,6 +1098,7 @@ files = ( 84F737F5287C13AD00A363F4 /* StreamVideo.framework in Frameworks */, 822FF71B2AEAD0B4000202A7 /* StreamSwiftTestHelpers in Frameworks */, + C0DE0C6E00000000000000C8 /* StreamCore in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1109,6 +1107,7 @@ buildActionMask = 2147483647; files = ( 84F73843287C195600A363F4 /* StreamVideo.framework in Frameworks */, + C0DE0C6E00000000000000C4 /* StreamCore in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1118,6 +1117,7 @@ files = ( 84F7380F287C141000A363F4 /* StreamVideoSwiftUI.framework in Frameworks */, 822FF71D2AEAD0BE000202A7 /* StreamSwiftTestHelpers in Frameworks */, + C0DE0C6E00000000000000C6 /* StreamCore in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1127,6 +1127,7 @@ files = ( 841F2C8029429DEC00D8D655 /* StreamVideoSwiftUI.framework in Frameworks */, 84F73848287C196B00A363F4 /* StreamVideo.framework in Frameworks */, + C0DE0C6E00000000000000C5 /* StreamCore in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1136,6 +1137,7 @@ files = ( 84F73830287C146D00A363F4 /* StreamVideoUIKit.framework in Frameworks */, 822FF71F2AEAD0C4000202A7 /* StreamSwiftTestHelpers in Frameworks */, + C0DE0C6E00000000000000C7 /* StreamCore in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1368,6 +1370,7 @@ packageProductDependencies = ( 84BE8A5528BE314000B34D2F /* SwiftProtobuf */, 40B8FFA62EC393A80061E3F6 /* StreamWebRTC */, + C0DE0C6E00000000000000B2 /* StreamCore */, ); productName = StreamVideo; productReference = 84F737ED287C13AC00A363F4 /* StreamVideo.framework */; @@ -1392,6 +1395,7 @@ name = StreamVideoTests; packageProductDependencies = ( 822FF71A2AEAD0B4000202A7 /* StreamSwiftTestHelpers */, + C0DE0C6E00000000000000B7 /* StreamCore */, ); productName = StreamVideoTests; productReference = 84F737F4287C13AD00A363F4 /* StreamVideoTests.xctest */; @@ -1414,6 +1418,7 @@ ); name = StreamVideoSwiftUI; packageProductDependencies = ( + C0DE0C6E00000000000000B3 /* StreamCore */, ); productName = StreamVideoSwiftUI; productReference = 84F73807287C141000A363F4 /* StreamVideoSwiftUI.framework */; @@ -1438,6 +1443,7 @@ name = StreamVideoSwiftUITests; packageProductDependencies = ( 822FF71C2AEAD0BE000202A7 /* StreamSwiftTestHelpers */, + C0DE0C6E00000000000000B5 /* StreamCore */, ); productName = StreamVideoSwiftUITests; productReference = 84F7380E287C141000A363F4 /* StreamVideoSwiftUITests.xctest */; @@ -1460,6 +1466,7 @@ ); name = StreamVideoUIKit; packageProductDependencies = ( + C0DE0C6E00000000000000B4 /* StreamCore */, ); productName = StreamVideoUIKit; productReference = 84F73828287C146D00A363F4 /* StreamVideoUIKit.framework */; @@ -1484,6 +1491,7 @@ name = StreamVideoUIKitTests; packageProductDependencies = ( 822FF71E2AEAD0C4000202A7 /* StreamSwiftTestHelpers */, + C0DE0C6E00000000000000B6 /* StreamCore */, ); productName = StreamVideoUIKitTests; productReference = 84F7382F287C146D00A363F4 /* StreamVideoUIKitTests.xctest */; @@ -1557,6 +1565,7 @@ 40AC73B22BE0062B00C57517 /* XCRemoteSwiftPackageReference "stream-video-noise-cancellation-swift" */, 4014F1012D8C2EBC004E7EFD /* XCRemoteSwiftPackageReference "Gleap-iOS-SDK" */, 40B8FFA52EC393A80061E3F6 /* XCRemoteSwiftPackageReference "stream-video-swift-webrtc" */, + C0DE0C6E00000000000000A1 /* XCRemoteSwiftPackageReference "stream-core-swift" */, ); preferredProjectObjectVersion = 77; productRefGroup = 842D8BC42865B31B00801910 /* Products */; @@ -3287,7 +3296,7 @@ repositoryURL = "https://github.com/GetStream/stream-chat-swiftui"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 5.5.0; + minimumVersion = 5.7.0; }; }; 401A64A62A9DF7B400534ED1 /* XCRemoteSwiftPackageReference "effects-library" */ = { @@ -3354,6 +3363,14 @@ version = 1.30.0; }; }; + C0DE0C6E00000000000000A1 /* XCRemoteSwiftPackageReference "stream-core-swift" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/GetStream/stream-core-swift.git"; + requirement = { + branch = develop; + kind = branch; + }; + }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -3462,6 +3479,36 @@ package = 84BE8A5428BE314000B34D2F /* XCRemoteSwiftPackageReference "swift-protobuf" */; productName = SwiftProtobuf; }; + C0DE0C6E00000000000000B2 /* StreamCore */ = { + isa = XCSwiftPackageProductDependency; + package = C0DE0C6E00000000000000A1 /* XCRemoteSwiftPackageReference "stream-core-swift" */; + productName = StreamCore; + }; + C0DE0C6E00000000000000B3 /* StreamCore */ = { + isa = XCSwiftPackageProductDependency; + package = C0DE0C6E00000000000000A1 /* XCRemoteSwiftPackageReference "stream-core-swift" */; + productName = StreamCore; + }; + C0DE0C6E00000000000000B4 /* StreamCore */ = { + isa = XCSwiftPackageProductDependency; + package = C0DE0C6E00000000000000A1 /* XCRemoteSwiftPackageReference "stream-core-swift" */; + productName = StreamCore; + }; + C0DE0C6E00000000000000B5 /* StreamCore */ = { + isa = XCSwiftPackageProductDependency; + package = C0DE0C6E00000000000000A1 /* XCRemoteSwiftPackageReference "stream-core-swift" */; + productName = StreamCore; + }; + C0DE0C6E00000000000000B6 /* StreamCore */ = { + isa = XCSwiftPackageProductDependency; + package = C0DE0C6E00000000000000A1 /* XCRemoteSwiftPackageReference "stream-core-swift" */; + productName = StreamCore; + }; + C0DE0C6E00000000000000B7 /* StreamCore */ = { + isa = XCSwiftPackageProductDependency; + package = C0DE0C6E00000000000000A1 /* XCRemoteSwiftPackageReference "stream-core-swift" */; + productName = StreamCore; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 842D8BBB2865B31B00801910 /* Project object */; diff --git a/StreamVideo.xcodeproj/xcshareddata/xcschemes/DemoApp.xcscheme b/StreamVideo.xcodeproj/xcshareddata/xcschemes/DemoApp.xcscheme index 9980fe7ed..792fcc389 100644 --- a/StreamVideo.xcodeproj/xcshareddata/xcschemes/DemoApp.xcscheme +++ b/StreamVideo.xcodeproj/xcshareddata/xcschemes/DemoApp.xcscheme @@ -74,7 +74,8 @@ debugDocumentVersioning = "YES" debugServiceExtension = "internal" enableGPUValidationMode = "1" - allowLocationSimulation = "YES"> + allowLocationSimulation = "YES" + queueDebuggingEnabled = "No"> diff --git a/StreamVideoSwiftUITests/CallViewModel_Tests.swift b/StreamVideoSwiftUITests/CallViewModel_Tests.swift index f5d02735d..ca718dcd3 100644 --- a/StreamVideoSwiftUITests/CallViewModel_Tests.swift +++ b/StreamVideoSwiftUITests/CallViewModel_Tests.swift @@ -44,6 +44,23 @@ final class CallViewModel_Tests: XCTestCase, @unchecked Sendable { try await super.tearDown() } + func test_error_apiError_setsToastMessage() { + let message = "Token generation failed with error" + subject = .init() + + subject.error = APIError( + code: 0, + details: [], + duration: "", + message: message, + moreInfo: "", + statusCode: 500 + ) + + XCTAssertEqual(subject.toast?.message, message) + XCTAssertTrue(subject.errorAlertShown) + } + @MainActor func test_startCall_withoutLocalCallSettingsAndRingTrue_respectsDashboardSettings() async throws { // Given diff --git a/StreamVideoSwiftUITests/Utils/PictureInPicture/PictureInPictureContentProviderTests.swift b/StreamVideoSwiftUITests/Utils/PictureInPicture/PictureInPictureContentProviderTests.swift index 4d975c518..12dd60eff 100644 --- a/StreamVideoSwiftUITests/Utils/PictureInPicture/PictureInPictureContentProviderTests.swift +++ b/StreamVideoSwiftUITests/Utils/PictureInPicture/PictureInPictureContentProviderTests.swift @@ -50,7 +50,7 @@ final class PictureInPictureContentProviderTests: XCTestCase, @unchecked Sendabl func test_internetConnectionDrops_contentUpdates() async throws { let mockInternetConnection = MockInternetConnection() - InternetConnection.currentValue = mockInternetConnection + VideoInternetConnection.currentValue = mockInternetConnection try await assertContentUpdate { _ in mockInternetConnection.subject.send(.unavailable) diff --git a/StreamVideoTests/Call/Call_JoinRecovery_Tests.swift b/StreamVideoTests/Call/Call_JoinRecovery_Tests.swift index 4c68013b3..a2e4b1c50 100644 --- a/StreamVideoTests/Call/Call_JoinRecovery_Tests.swift +++ b/StreamVideoTests/Call/Call_JoinRecovery_Tests.swift @@ -94,6 +94,17 @@ final class Call_JoinRecovery_Tests: StreamVideoTestCase, @unchecked Sendable { ) } + await fulfilmentInMainActor(timeout: defaultTimeout) { + webRTCCoordinatorFactory + .mockCoordinatorStack + .sfuStack + .webSocket + .timesCalled(.connect) == 1 + } + webRTCCoordinatorFactory + .mockCoordinatorStack + .sfuStack + .setConnectionState(to: .authenticating) await fulfilmentInMainActor(timeout: defaultTimeout) { webRTCCoordinatorFactory .mockCoordinatorStack @@ -388,7 +399,6 @@ final class Call_JoinRecovery_Tests: StreamVideoTestCase, @unchecked Sendable { .adapter ) ) - let refreshedWebSocket = MockWebSocketClient(webSocketClientType: .sfu) defaultAPI.stub(for: .joinCall, with: joinResponse) webRTCCoordinatorFactory .mockCoordinatorStack @@ -398,11 +408,6 @@ final class Call_JoinRecovery_Tests: StreamVideoTestCase, @unchecked Sendable { .mockCoordinatorStack .rtcPeerConnectionCoordinatorFactory .stubbedBuildCoordinatorResult[.subscriber] = subscriber - webRTCCoordinatorFactory - .mockCoordinatorStack - .sfuStack - .webSocketFactory - .stub(for: .build, with: refreshedWebSocket) webRTCCoordinatorFactory .mockCoordinatorStack .coordinator @@ -426,6 +431,17 @@ final class Call_JoinRecovery_Tests: StreamVideoTestCase, @unchecked Sendable { ) } + await fulfilmentInMainActor(timeout: defaultTimeout) { + webRTCCoordinatorFactory + .mockCoordinatorStack + .sfuStack + .webSocket + .timesCalled(.connect) == 1 + } + webRTCCoordinatorFactory + .mockCoordinatorStack + .sfuStack + .setConnectionState(to: .authenticating) await fulfilmentInMainActor(timeout: defaultTimeout) { webRTCCoordinatorFactory .mockCoordinatorStack @@ -435,25 +451,75 @@ final class Call_JoinRecovery_Tests: StreamVideoTestCase, @unchecked Sendable { .id == .joining } + await fulfilmentInMainActor(timeout: defaultTimeout) { + webRTCCoordinatorFactory + .mockCoordinatorStack + .sfuStack + .webSocket + .recordedInputPayload( + Stream_Video_Sfu_Event_SfuRequest.self, + for: .send + )? + .contains { + if case .joinRequest = $0.requestPayload { + return true + } + return false + } == true + } + webRTCCoordinatorFactory + .mockCoordinatorStack + .sfuStack + .receiveEvent(.joinResponse(.init())) webRTCCoordinatorFactory .mockCoordinatorStack .sfuStack .setConnectionState(to: .connected(healthCheckInfo: .init())) - webRTCCoordinatorFactory.mockCoordinatorStack.joinResponse([]) _ = try await joinTask.value + await fulfilmentInMainActor(timeout: defaultTimeout) { + webRTCCoordinatorFactory + .mockCoordinatorStack + .coordinator + .stateMachine + .currentStage + .id == .joined + } + XCTAssertEqual(defaultAPI.timesCalled(.joinCall), 1) webRTCCoordinatorFactory.mockCoordinatorStack.sfuStack.setConnectionState( to: .disconnected(source: .serverInitiated()) ) - refreshedWebSocket.simulate(state: .connected(healthCheckInfo: .init())) - refreshedWebSocket.eventSubject.send(.sfuEvent(.joinResponse(.init()))) - - await fulfilmentInMainActor(timeout: defaultTimeout + 2) { + await fulfilmentInMainActor(timeout: defaultTimeout) { defaultAPI.timesCalled(.joinCall) >= 2 } + await fulfilmentInMainActor(timeout: defaultTimeout) { + webRTCCoordinatorFactory + .mockCoordinatorStack + .sfuStack + .webSocket + .recordedInputPayload( + Stream_Video_Sfu_Event_SfuRequest.self, + for: .send + )? + .filter { + if case .joinRequest = $0.requestPayload { + return true + } + return false + } + .count == 2 + } + webRTCCoordinatorFactory + .mockCoordinatorStack + .sfuStack + .receiveEvent(.joinResponse(.init())) + webRTCCoordinatorFactory + .mockCoordinatorStack + .sfuStack + .setConnectionState(to: .connected(healthCheckInfo: .init())) let joinCallRequests = try XCTUnwrap( defaultAPI.recordedInputPayload( @@ -567,7 +633,9 @@ final class CallAuthenticationBackedWebRTCAuthenticator: return (sfuAdapter, response) } - func waitForAuthentication(on sfuAdapter: SFUAdapter) async throws {} + func waitForAuthentication(on sfuAdapter: SFUAdapter) async throws { + sfuAdapter.connect() + } func waitForConnect(on sfuAdapter: SFUAdapter) async throws {} } diff --git a/StreamVideoTests/Call/Call_Tests.swift b/StreamVideoTests/Call/Call_Tests.swift index 533def455..889ab99ef 100644 --- a/StreamVideoTests/Call/Call_Tests.swift +++ b/StreamVideoTests/Call/Call_Tests.swift @@ -932,7 +932,7 @@ final class Call_Tests: StreamVideoTestCase, @unchecked Sendable { streamVideo .eventNotificationCenter - .process(.coordinatorEvent(event)) + .process(WrappedEvent.coordinatorEvent(event)) try await fulfillmentHandler(call) } diff --git a/StreamVideoTests/CallKit/CallKitServiceTests.swift b/StreamVideoTests/CallKit/CallKitServiceTests.swift index c9f669361..85da9fed1 100644 --- a/StreamVideoTests/CallKit/CallKitServiceTests.swift +++ b/StreamVideoTests/CallKit/CallKitServiceTests.swift @@ -5,6 +5,7 @@ import AVFoundation import CallKit import Foundation +import StreamCore @testable import StreamVideo @preconcurrency import XCTest @@ -1725,7 +1726,9 @@ final class CallKitServiceTests: XCTestCase, @unchecked Sendable { await safeFulfillment(of: [waitExpectation], timeout: timeout) } - private func stubConnectionState(to status: ConnectionStatus) { + private func stubConnectionState( + to status: ConnectionStatus + ) { let mockedState = mockedStreamVideo.state mockedState.connection = status mockedStreamVideo.stub(for: \.state, with: mockedState) diff --git a/StreamVideoTests/Controllers/CallsController_Tests.swift b/StreamVideoTests/Controllers/CallsController_Tests.swift index c332df94e..1efbda14e 100644 --- a/StreamVideoTests/Controllers/CallsController_Tests.swift +++ b/StreamVideoTests/Controllers/CallsController_Tests.swift @@ -99,7 +99,7 @@ final class CallsController_Tests: ControllerTestCase, @unchecked Sendable { private func makeTestCallsController(watch: Bool = true) throws -> CallsController { let response = mockResponseBuilder.makeQueryCallsResponse() - let data = try JSONEncoder.default.encode(response) + let data = try JSONEncoder.streamCore.encode(response) httpClient.dataResponses = [data, data] let query = CallsQuery(sortParams: [], watch: watch) let callsController = CallsController( diff --git a/StreamVideoTests/Errors/ClientError_Tests.swift b/StreamVideoTests/Errors/ClientError_Tests.swift index 9b91ff894..fddc7cc23 100644 --- a/StreamVideoTests/Errors/ClientError_Tests.swift +++ b/StreamVideoTests/Errors/ClientError_Tests.swift @@ -2,44 +2,112 @@ // Copyright © 2026 Stream.io Inc. All rights reserved. // +import Foundation +import StreamCore @testable import StreamVideo import XCTest final class ClientError_Tests: XCTestCase, @unchecked Sendable { - func test_isInvalidTokenError_whenUnderlayingErrorIsInvalidToken_returnsTrue() { - // Create error code withing `ErrorPayload.tokenInvalidErrorCodes` range - let error = ErrorPayload( - code: .random(in: ClosedRange.tokenInvalidErrorCodes), - message: .unique, - statusCode: .unique + func test_init_videoAPIError_preservesPublicAPIError() throws { + let apiError = makeAPIError(code: 1) + + let subject = ClientError(with: apiError) + let exposedAPIError = try XCTUnwrap(subject.apiError) + + XCTAssertTrue(exposedAPIError === apiError) + } + + func test_encodeToJSON_preservesLegacyWireShape() throws { + let subject = makeAPIError(code: 1) + let jsonEncodable: any JSONEncodable = subject + + let encoded = try XCTUnwrap(jsonEncodable.encodeToJSON() as? String) + let data = try XCTUnwrap(Data(base64Encoded: encoded)) + + AssertJSONEqual( + data, + """ + { + "code": 1, + "details": [2], + "duration": "3ms", + "exception_fields": {"field": "reason"}, + "message": "message", + "more_info": "more info", + "StatusCode": 400, + "unrecoverable": false + } + """.data(using: .utf8)! ) + } - // Assert `isInvalidTokenError` returns true - XCTAssertTrue(error.isInvalidTokenError) + func test_APIError_equality_preservesLegacyFields() { + let subject = makeAPIError(code: 1) + let equalValue = makeAPIError(code: 1) + let differentValue = makeAPIError(code: 2) - // Create client error wrapping the error - let clientError = ClientError(with: error) + XCTAssertEqual(subject, equalValue) + XCTAssertNotEqual(subject, differentValue) + } - // Assert `isInvalidTokenError` returns true - XCTAssertTrue(clientError.isInvalidTokenError) + func test_errorPayload_isInvalidTokenError_preservesLegacyBoundaries() { + let cases = [ + (code: 39, expected: false), + (code: 40, expected: true), + (code: 42, expected: true), + (code: 43, expected: false) + ] + + for testCase in cases { + let errorPayload = ErrorPayload( + code: testCase.code, + message: "message", + statusCode: 401 + ) + + XCTAssertEqual( + errorPayload.isInvalidTokenError, + testCase.expected, + "ErrorPayload code \(testCase.code)" + ) + } } - func test_isInvalidTokenError_whenUnderlayingErrorIsNotInvalidToken_returnsFalse() { - // Create error code outside `ErrorPayload.tokenInvalidErrorCodes` range - let error = ErrorPayload( - code: ClosedRange.tokenInvalidErrorCodes.lowerBound - 1, - message: .unique, - statusCode: .unique - ) + func test_clientError_isInvalidTokenError_usesStreamCoreBoundaries() { + let cases = [ + (code: 1, expected: false), + (code: 2, expected: true), + (code: 40, expected: false), + (code: 41, expected: true), + (code: 43, expected: true), + (code: 44, expected: false) + ] - // Assert `isInvalidTokenError` returns false - XCTAssertFalse(error.isInvalidTokenError) + for testCase in cases { + XCTAssertEqual( + ClientError(with: makeAPIError(code: testCase.code)) + .isInvalidTokenError, + testCase.expected, + "APIError code \(testCase.code)" + ) + } + } - // Create client error wrapping the error - let clientError = ClientError(with: error) + func test_clientError_isTokenExpiredError_usesStreamCoreBoundary() { + let cases = [ + (code: 39, expected: false), + (code: 40, expected: true), + (code: 41, expected: false) + ] - // Assert `isInvalidTokenError` returns false - XCTAssertFalse(clientError.isInvalidTokenError) + for testCase in cases { + XCTAssertEqual( + ClientError(with: makeAPIError(code: testCase.code)) + .isTokenExpiredError, + testCase.expected, + "APIError code \(testCase.code)" + ) + } } func test_rateLimitError_isEphemeralError() { @@ -54,4 +122,17 @@ final class ClientError_Tests: XCTestCase, @unchecked Sendable { // Assert `isRateLimitError` returns true XCTAssertTrue(error.isRateLimitError) } + + private func makeAPIError(code: Int) -> APIError { + APIError( + code: code, + details: [2], + duration: "3ms", + exceptionFields: ["field": "reason"], + message: "message", + moreInfo: "more info", + statusCode: 400, + unrecoverable: false + ) + } } diff --git a/StreamVideoTests/HTTPClient/InternetConnection_Tests.swift b/StreamVideoTests/HTTPClient/InternetConnection_Tests.swift index 7a3843e83..02f80beee 100644 --- a/StreamVideoTests/HTTPClient/InternetConnection_Tests.swift +++ b/StreamVideoTests/HTTPClient/InternetConnection_Tests.swift @@ -7,7 +7,7 @@ import XCTest final class InternetConnection_Tests: XCTestCase, @unchecked Sendable { private var monitor: InternetConnectionMonitor_Mock! = .init() - private lazy var subject: InternetConnection! = .init(monitor: monitor) + private lazy var subject: VideoInternetConnection! = .init(monitor: monitor) override func setUp() async throws { try await super.setUp() diff --git a/StreamVideoTests/Mock/EventBatcher_Mock.swift b/StreamVideoTests/Mock/EventBatcher_Mock.swift deleted file mode 100644 index 6c599ce5e..000000000 --- a/StreamVideoTests/Mock/EventBatcher_Mock.swift +++ /dev/null @@ -1,35 +0,0 @@ -// -// Copyright © 2026 Stream.io Inc. All rights reserved. -// - -import Foundation -@testable import StreamVideo -import protocol StreamVideo.Timer - -final class EventBatcher_Mock: EventBatcher, @unchecked Sendable { - var currentBatch: [WrappedEvent] = [] - - let handler: @Sendable (_ batch: [WrappedEvent], _ completion: @escaping @Sendable () -> Void) -> Void - - init( - period: TimeInterval = 0, - timerType: Timer.Type = DefaultTimer.self, - handler: @escaping @Sendable (_ batch: [WrappedEvent], _ completion: @escaping @Sendable () -> Void) -> Void - ) { - self.handler = handler - } - - lazy var mock_append = MockFunc.mock(for: append) - - func append(_ event: WrappedEvent) { - mock_append.call(with: (event)) - - handler([event]) {} - } - - lazy var mock_processImmediately = MockFunc.mock(for: processImmediately) - - func processImmediately(completion: @escaping () -> Void) { - mock_processImmediately.call(with: (completion)) - } -} diff --git a/StreamVideoTests/Mock/EventDecoder_Mock.swift b/StreamVideoTests/Mock/EventDecoder_Mock.swift deleted file mode 100644 index 6bd29e52a..000000000 --- a/StreamVideoTests/Mock/EventDecoder_Mock.swift +++ /dev/null @@ -1,25 +0,0 @@ -// -// Copyright © 2026 Stream.io Inc. All rights reserved. -// - -import Foundation -@testable import StreamVideo -import XCTest - -final class EventDecoder_Mock: AnyEventDecoder, @unchecked Sendable { - var decode_calledWithData: Data? - var decodedEvent: Result! - - func decode(from data: Data) throws -> WrappedEvent { - decode_calledWithData = data - - switch decodedEvent { - case let .success(event): return event - case let .failure(error): throw error - case .none: - XCTFail("Undefined state, `decodedEvent` should not be nil") - // just dummy error to make compiler happy - throw NSError(domain: "some error", code: 0, userInfo: nil) - } - } -} diff --git a/StreamVideoTests/Mock/EventMiddleware_Mock.swift b/StreamVideoTests/Mock/EventMiddleware_Mock.swift deleted file mode 100644 index 5089275ad..000000000 --- a/StreamVideoTests/Mock/EventMiddleware_Mock.swift +++ /dev/null @@ -1,19 +0,0 @@ -// -// Copyright © 2026 Stream.io Inc. All rights reserved. -// - -import Foundation -@testable import StreamVideo - -/// A test middleware that can be initiated with a closure -final class EventMiddleware_Mock: EventMiddleware, @unchecked Sendable { - var closure: (WrappedEvent) -> WrappedEvent? - - init(closure: @escaping (WrappedEvent) -> WrappedEvent? = { event in event }) { - self.closure = closure - } - - func handle(event: WrappedEvent) -> WrappedEvent? { - closure(event) - } -} diff --git a/StreamVideoTests/Mock/EventNotificationCenter_Mock.swift b/StreamVideoTests/Mock/EventNotificationCenter_Mock.swift deleted file mode 100644 index da6d2d3ea..000000000 --- a/StreamVideoTests/Mock/EventNotificationCenter_Mock.swift +++ /dev/null @@ -1,22 +0,0 @@ -// -// Copyright © 2026 Stream.io Inc. All rights reserved. -// - -import Foundation -@testable import StreamVideo - -/// Mock implementation of `EventNotificationCenter` -final class EventNotificationCenter_Mock: EventNotificationCenter, @unchecked Sendable { - - lazy var mock_process = MockFunc<([WrappedEvent], Bool, (@Sendable () -> Void)?), Void>.mock(for: process) - - override func process( - _ events: [WrappedEvent], - postNotifications: Bool = true, - completion: (@Sendable () -> Void)? = nil - ) { - super.process(events, postNotifications: postNotifications, completion: completion) - - mock_process.call(with: (events, postNotifications, completion)) - } -} diff --git a/StreamVideoTests/Mock/MockSFUStack.swift b/StreamVideoTests/Mock/MockSFUStack.swift index 3238c29e0..04ecdee16 100644 --- a/StreamVideoTests/Mock/MockSFUStack.swift +++ b/StreamVideoTests/Mock/MockSFUStack.swift @@ -2,39 +2,67 @@ // Copyright © 2026 Stream.io Inc. All rights reserved. // +import StreamCore @testable import StreamVideo struct MockSFUStack: @unchecked Sendable { - var webSocket: MockWebSocketClient - let webSocketFactory: MockWebSocketClientFactory + private final class WebSocketStorage: @unchecked Sendable { + private let lock = NSLock() + private var value: MockSFUWebSocket + + init(_ value: MockSFUWebSocket) { + self.value = value + } + + var current: MockSFUWebSocket { + get { + lock.lock() + defer { lock.unlock() } + return value + } + set { + lock.lock() + value = newValue + lock.unlock() + } + } + } + + var webSocket: MockSFUWebSocket var service: MockSignalServer let adapter: SFUAdapter + private let webSocketStorage: WebSocketStorage + + var nextWebSocket: MockSFUWebSocket { + get { webSocketStorage.current } + nonmutating set { webSocketStorage.current = newValue } + } init() { - let webSocket = MockWebSocketClient(webSocketClientType: .sfu) - let webSocketFactory = MockWebSocketClientFactory() + let webSocket = MockSFUWebSocket() let service = MockSignalServer() + let webSocketStorage = WebSocketStorage(webSocket) self.webSocket = webSocket self.service = service - self.webSocketFactory = webSocketFactory + self.webSocketStorage = webSocketStorage adapter = SFUAdapter( signalService: service, webSocket: webSocket, - webSocketFactory: webSocketFactory + webSocketFactory: { [webSocketStorage] _, _, _ in + webSocketStorage.current + } ) } // MARK: - WebSocket func setConnectionState(to state: WebSocketConnectionState) { - webSocket.stub(for: \.connectionState, with: state) - webSocket.connectionStateDelegate?.webSocketClient( - webSocket, - didUpdateConnectionState: state - ) + webSocket.simulate(state: state) } - func receiveEvent(_ event: WrappedEvent) { - webSocket.eventSubject.send(event) + func receiveEvent( + _ payload: Stream_Video_Sfu_Event_SfuEvent.OneOf_EventPayload + ) { + webSocket.inject(payload) } } diff --git a/StreamVideoTests/Mock/MockWebRTCCoordinatorStack.swift b/StreamVideoTests/Mock/MockWebRTCCoordinatorStack.swift index fa42b91a6..302407da7 100644 --- a/StreamVideoTests/Mock/MockWebRTCCoordinatorStack.swift +++ b/StreamVideoTests/Mock/MockWebRTCCoordinatorStack.swift @@ -75,21 +75,21 @@ final class MockWebRTCCoordinatorStack: @unchecked Sendable { func joinResponse(_ participants: [CallParticipant]) { var event = Stream_Video_Sfu_Event_JoinResponse() event.callState.participants = participants.map { .init($0) } - sfuStack.receiveEvent(.sfuEvent(.joinResponse(event))) + sfuStack.receiveEvent(.joinResponse(event)) } func participantJoined(_ participant: CallParticipant) { var event = Stream_Video_Sfu_Event_ParticipantJoined() event.participant = .init(participant) event.callCid = callCid - sfuStack.receiveEvent(.sfuEvent(.participantJoined(event))) + sfuStack.receiveEvent(.participantJoined(event)) } func participantLeft(_ participant: CallParticipant) { var event = Stream_Video_Sfu_Event_ParticipantLeft() event.participant = .init(participant) event.callCid = callCid - sfuStack.receiveEvent(.sfuEvent(.participantLeft(event))) + sfuStack.receiveEvent(.participantLeft(event)) } func addTrack( @@ -124,7 +124,7 @@ final class MockWebRTCCoordinatorStack: @unchecked Sendable { .sink { [weak self] _ in guard let self else { return } let event = Stream_Video_Sfu_Event_HealthCheckResponse() - sfuStack.receiveEvent(.sfuEvent(.healthCheckResponse(event))) + sfuStack.receiveEvent(.healthCheckResponse(event)) } } } diff --git a/StreamVideoTests/Mock/MockWebSocketClientFactory.swift b/StreamVideoTests/Mock/MockWebSocketClientFactory.swift deleted file mode 100644 index 470664103..000000000 --- a/StreamVideoTests/Mock/MockWebSocketClientFactory.swift +++ /dev/null @@ -1,88 +0,0 @@ -// -// Copyright © 2026 Stream.io Inc. All rights reserved. -// - -@testable import StreamVideo - -final class MockWebSocketClientFactory: WebSocketClientProviding, Mockable, @unchecked Sendable { - - // MARK: - Mockable - - typealias FunctionKey = MockFunctionKey - typealias FunctionInputKey = MockFunctionInputKey - enum MockFunctionKey: CaseIterable { case build } - enum MockFunctionInputKey: Payloadable { - case build( - sessionConfiguration: URLSessionConfiguration, - eventDecoder: any AnyEventDecoder, - eventNotificationCenter: EventNotificationCenter, - webSocketClientType: WebSocketClientType, - environment: WebSocketClient.Environment, - connectURL: URL, - requiresAuth: Bool - ) - - var payload: Any { - switch self { - case let .build( - sessionConfiguration, - eventDecoder, - eventNotificationCenter, - webSocketClientType, - environment, - connectURL, - requiresAuth - ): - return ( - sessionConfiguration, - eventDecoder, - eventNotificationCenter, - webSocketClientType, - environment, - connectURL, - requiresAuth - ) - } - } - } - - var stubbedProperty: [String: Any] = [:] - var stubbedFunction: [FunctionKey: Any] = [:] - @Atomic var stubbedFunctionInput: [FunctionKey: [MockFunctionInputKey]] = FunctionKey.allCases - .reduce(into: [FunctionKey: [MockFunctionInputKey]]()) { $0[$1] = [] } - func stub(for keyPath: KeyPath, with value: T) { - stubbedProperty[propertyKey(for: keyPath)] = value - } - - func stub(for function: FunctionKey, with value: T) { stubbedFunction[function] = value } - - init() { - stub(for: .build, with: MockWebSocketClient(webSocketClientType: .sfu)) - } - - // MARK: - WebSocketClientProviding - - func build( - sessionConfiguration: URLSessionConfiguration, - eventDecoder: any AnyEventDecoder, - eventNotificationCenter: EventNotificationCenter, - webSocketClientType: WebSocketClientType, - environment: WebSocketClient.Environment, - connectURL: URL, - requiresAuth: Bool - ) -> WebSocketClient { - stubbedFunctionInput[.build]? - .append( - .build( - sessionConfiguration: sessionConfiguration, - eventDecoder: eventDecoder, - eventNotificationCenter: eventNotificationCenter, - webSocketClientType: webSocketClientType, - environment: environment, - connectURL: connectURL, - requiresAuth: requiresAuth - ) - ) - return stubbedFunction[.build] as! WebSocketClient - } -} diff --git a/StreamVideoTests/Mock/WebSocketClientEnvironment_Mock.swift b/StreamVideoTests/Mock/WebSocketClientEnvironment_Mock.swift deleted file mode 100644 index 7d863b8e3..000000000 --- a/StreamVideoTests/Mock/WebSocketClientEnvironment_Mock.swift +++ /dev/null @@ -1,16 +0,0 @@ -// -// Copyright © 2026 Stream.io Inc. All rights reserved. -// - -import Foundation -@testable import StreamVideo - -extension WebSocketClient.Environment { - static var mock: Self { - .init( - createPingController: WebSocketPingController_Mock.init, - createEngine: WebSocketEngine_Mock.init, - eventBatcherBuilder: { EventBatcher_Mock(handler: $0) } - ) - } -} diff --git a/StreamVideoTests/Mock/WebSocketEngine_Mock.swift b/StreamVideoTests/Mock/WebSocketEngine_Mock.swift deleted file mode 100644 index cf07b3601..000000000 --- a/StreamVideoTests/Mock/WebSocketEngine_Mock.swift +++ /dev/null @@ -1,92 +0,0 @@ -// -// Copyright © 2026 Stream.io Inc. All rights reserved. -// - -import Foundation -@testable import StreamVideo - -final class WebSocketEngine_Mock: WebSocketEngine, @unchecked Sendable { - - var request: URLRequest - var sessionConfiguration: URLSessionConfiguration - var isConnected: Bool = false - var callbackQueue: DispatchQueue - weak var delegate: WebSocketEngineDelegate? - - /// How many times was `connect()` called - @Atomic var connect_calledCount = 0 - - /// How many times was `disconnect()` called - @Atomic var disconnect_calledCount = 0 - @Atomic var disconnect_closeCode: URLSessionWebSocketTask.CloseCode? - - /// How many times was `sendPing()` called - @Atomic var sendPing_calledCount = 0 - - convenience init() { - self.init(request: .init(url: URL(string: "test_url")!), sessionConfiguration: .ephemeral, callbackQueue: .main) - } - - required init(request: URLRequest, sessionConfiguration: URLSessionConfiguration, callbackQueue: DispatchQueue) { - self.request = request - self.sessionConfiguration = sessionConfiguration - self.callbackQueue = callbackQueue - } - - func connect() { - connect_calledCount += 1 - } - - func disconnect() { - disconnect_calledCount += 1 - } - - func disconnect(with code: URLSessionWebSocketTask.CloseCode) { - disconnect_closeCode = code - disconnect_calledCount += 1 - } - - func sendPing() { - sendPing_calledCount += 1 - } - - func send(message: SendableEvent) {} - - func send(jsonMessage: Codable) {} - - // MARK: - Functions to simulate behavior - - func simulateConnectionSuccess() { - isConnected = true - delegate?.webSocketDidConnect() - } - - func simulateMessageReceived(_ json: [String: Any] = [:]) { - let data = try! JSONSerialization.data(withJSONObject: json, options: []) - simulateMessageReceived(data) - } - - func simulateMessageReceived(_ data: Data) { - delegate?.webSocketDidReceiveMessage(data) - } - - func simulatePong() { - simulateMessageReceived(.healthCheckEvent(userId: .unique, connectionId: .unique)) - } - - func simulateDisconnect(_ error: WebSocketEngineError? = nil) { - isConnected = false - delegate?.webSocketDidDisconnect(error: error) - } -} - -extension Dictionary { - /// Helper function to create a `health.check` event JSON with the given `userId` and `connectId`. - static func healthCheckEvent(userId: String, connectionId: String) -> [String: Any] { - [ - "created_at": "2020-05-02T13:21:03.862065063Z", - "type": "health.check", - "connection_id": connectionId - ] - } -} diff --git a/StreamVideoTests/Mock/WebSocketPingController_Mock.swift b/StreamVideoTests/Mock/WebSocketPingController_Mock.swift deleted file mode 100644 index 90e167091..000000000 --- a/StreamVideoTests/Mock/WebSocketPingController_Mock.swift +++ /dev/null @@ -1,22 +0,0 @@ -// -// Copyright © 2026 Stream.io Inc. All rights reserved. -// - -import Foundation -@testable import StreamVideo -import XCTest - -final class WebSocketPingController_Mock: WebSocketPingController, @unchecked Sendable { - var connectionStateDidChange_connectionStates: [WebSocketConnectionState] = [] - var pongReceivedCount = 0 - - override func connectionStateDidChange(_ connectionState: WebSocketConnectionState) { - connectionStateDidChange_connectionStates.append(connectionState) - super.connectionStateDidChange(connectionState) - } - - override func pongReceived() { - pongReceivedCount += 1 - super.pongReceived() - } -} diff --git a/StreamVideoTests/Models/JSONDecoder_Tests.swift b/StreamVideoTests/Models/JSONDecoder_Tests.swift index 677f59310..bfd840043 100644 --- a/StreamVideoTests/Models/JSONDecoder_Tests.swift +++ b/StreamVideoTests/Models/JSONDecoder_Tests.swift @@ -3,11 +3,12 @@ // import Foundation +import StreamCore @testable import StreamVideo import XCTest final class JSONDecoder_Tests: XCTestCase, @unchecked Sendable { - private var decoder: JSONDecoder = .default + private var decoder: JSONDecoder = .streamCore func test_throwsException_whenDecodingDateFromEmptyString() { checkDecodingDateThrowException(dateString: "") @@ -17,8 +18,16 @@ final class JSONDecoder_Tests: XCTestCase, @unchecked Sendable { checkDecodingDateThrowException(dateString: "123456") } - func test_throwsException_whenDecodingDateFromNonRFC3339Date() { - checkDecodingDateThrowException(dateString: "2020-09-30T19:51:17") + func test_decodes_whenDecodingDateWithoutTimezone() throws { + try checkDateIsDecodingToComponents( + dateString: "2020-09-30T19:51:17", + year: 2020, + month: 9, + day: 30, + hour: 19, + minute: 51, + second: 17 + ) } func test_decodes_whenDecodingDateFromRFC3339DateWithMilliseconds() throws { @@ -106,67 +115,16 @@ final class JSONDecoder_Tests: XCTestCase, @unchecked Sendable { ) } - func test_datesAreCached() throws { - final class ISO8601DateFormatter_Spy: ISO8601DateFormatter { - var dateFromStringCalledCounter: Int = 0 - - override func date(from string: String) -> Date? { - dateFromStringCalledCounter += 1 - return super.date(from: string) - } - } - - final class NSCache_Spy: NSCache { - var setObjectCalledCounter: Int = 0 - var getObjectCalledCounter: Int = 0 - - override func object(forKey key: NSString) -> NSDate? { - getObjectCalledCounter += 1 - return super.object(forKey: key) - } - - override func setObject(_ obj: NSDate, forKey key: NSString) { - setObjectCalledCounter += 1 - super.setObject(obj, forKey: key) - } - } - - // Given a decoder with spy dateFormatter and cache - let dateFormatter = ISO8601DateFormatter_Spy() - dateFormatter.formatOptions = [.withFractionalSeconds, .withInternetDateTime] - let dateCache = NSCache_Spy() - let decoder = StreamJSONDecoder(dateFormatter: dateFormatter, dateCache: dateCache) - - // When we decode a payload with repeated dates - let repeatedDate = "2020-06-09T08:10:40.800912Z" // If you change this, make sure to change `actualDecodedDate` below - let jsonPayload = """ - { - "date1": "\(repeatedDate)", - "date2": "\(repeatedDate)", - "date3": "\(repeatedDate)", - "date4": "\(repeatedDate)", - "date5": "\(repeatedDate)" - } - """ - let dateDict = try decoder.decode([String: Date].self, from: jsonPayload.data(using: .utf8)!) - - // Then we should only decode the date once and use the cache - XCTAssertEqual(dateFormatter.dateFromStringCalledCounter, 1) - XCTAssertEqual(dateCache.setObjectCalledCounter, 1) - XCTAssertEqual(dateCache.getObjectCalledCounter, 5) + func test_decodes_whenDecodingDateFromRFC3339DateWithMicroseconds() throws { + let data = json(dateString: "2020-06-09T08:10:40.800912Z").data(using: .utf8)! + let decoded = try JSONDecoder.streamCore.decode([String: Date].self, from: data) + let date = try XCTUnwrap(decoded[dateKey]) - // The actual decoded date must match the date JSONDecoder decoded and cached - let actualDecodedDate = Date(timeIntervalSince1970: 1_591_690_240.8) XCTAssertEqual( - dateCache.object(forKey: repeatedDate as NSString)?.timeIntervalSince1970, - actualDecodedDate.timeIntervalSince1970 + date.timeIntervalSince1970, + 1_591_690_240.800_912, + accuracy: 0.000_001 ) - - // All dates must be decoded - XCTAssertEqual(dateDict.keys.count, 5) - for (_, value) in dateDict { - XCTAssertEqual(value, actualDecodedDate) - } } // MARK: Helpers @@ -229,15 +187,13 @@ final class JSONDecoder_Tests: XCTestCase, @unchecked Sendable { private func checkDecodingDateThrowException(dateString: String, file: StaticString = #filePath, line: UInt = #line) { // Given - let dateJson = json(dateString: "") + let dateJson = json(dateString: dateString) let data = dateJson.data(using: .utf8)! - do { - // When - _ = try decoder.decode([String: Date].self, from: data) - } catch { - // Then - XCTAssertNotNil(error, file: file, line: line) - } + XCTAssertThrowsError( + try decoder.decode([String: Date].self, from: data), + file: file, + line: line + ) } } diff --git a/StreamVideoTests/StreamCoreAmbiguity_Tests.swift b/StreamVideoTests/StreamCoreAmbiguity_Tests.swift new file mode 100644 index 000000000..a389660d9 --- /dev/null +++ b/StreamVideoTests/StreamCoreAmbiguity_Tests.swift @@ -0,0 +1,53 @@ +// +// Copyright © 2026 Stream.io Inc. All rights reserved. +// + +import StreamCore +import StreamVideo +import XCTest + +final class StreamCoreAmbiguity_Tests: XCTestCase, @unchecked Sendable { + func test_importingBothModules_resolvesSharedSymbols() { + let types: [Any.Type] = [ + APIError.self, + APIHelper.self, + APIKey.self, + BaseLogDestination.self, + ClientError.self, + CodableHelper.self, + ConnectUserDetailsRequest.self, + ConsoleLogDestination.self, + CreateDeviceRequest.self, + Device.self, + DisposableBag.self, + Injected.self, + InjectedValues.self, + InternetConnectionProtocol.self, + InternetConnectionQuality.self, + InternetConnectionStatus.self, + JSONDataEncoding.self, + ListDevicesResponse.self, + LogConfig.self, + LogDetails.self, + LogLevel.self, + LogSubsystem.self, + Logger.self, + ModelResponse.self, + NullEncodable.self, + OpenISO8601DateFormatter.self, + RawJSON.self, + RecursiveQueue.self, + Response.self, + StreamRuntimeCheck.self, + UnfairQueue.self, + User.self, + UserAuthType.self, + UserToken.self, + UserTokenProvider.self, + UserTokenUpdater.self, + WSAuthMessageRequest.self + ] + + _ = types + } +} diff --git a/StreamVideoTests/StreamCoreReexport_Tests.swift b/StreamVideoTests/StreamCoreReexport_Tests.swift new file mode 100644 index 000000000..36328c50a --- /dev/null +++ b/StreamVideoTests/StreamCoreReexport_Tests.swift @@ -0,0 +1,12 @@ +// +// Copyright © 2026 Stream.io Inc. All rights reserved. +// + +import StreamVideo +import XCTest + +final class StreamCoreReexport_Tests: XCTestCase, @unchecked Sendable { + func test_streamVideo_reexportsStreamCore() { + _ = DefaultEventNotificationCenter() + } +} diff --git a/StreamVideoTests/StreamVideo_Tests.swift b/StreamVideoTests/StreamVideo_Tests.swift index caf59d34a..766de46a9 100644 --- a/StreamVideoTests/StreamVideo_Tests.swift +++ b/StreamVideoTests/StreamVideo_Tests.swift @@ -185,7 +185,7 @@ final class StreamVideo_Tests: StreamVideoTestCase, @unchecked Sendable { func test_streamVideo_ringCallReject() async throws { let httpClient = httpClientWithGetCallResponse() let rejectCallResponse = RejectCallResponse(duration: "1") - let data = try! JSONEncoder.default.encode(rejectCallResponse) + let data = try! JSONEncoder.streamCore.encode(rejectCallResponse) let streamVideo = StreamVideo.mock(httpClient: httpClient) self.streamVideo = streamVideo let call = streamVideo.call(callType: callType, callId: callId) @@ -224,7 +224,7 @@ final class StreamVideo_Tests: StreamVideoTestCase, @unchecked Sendable { members: [], ownCapabilities: [] ) - httpClient.dataResponses = [try JSONEncoder.default.encode(getCallResponse)] + httpClient.dataResponses = [try JSONEncoder.streamCore.encode(getCallResponse)] nonisolated(unsafe) weak var previousCall: Call? do { @@ -367,7 +367,9 @@ final class StreamVideo_Tests: StreamVideoTestCase, @unchecked Sendable { // When let coordinatorEvent = VideoEvent.typeCallAcceptedEvent(.dummy(callCid: "cid")) let internalEvent = WrappedEvent.internalEvent(WSConnected()) - streamVideo.eventNotificationCenter.process(.coordinatorEvent(coordinatorEvent)) + streamVideo.eventNotificationCenter.process( + WrappedEvent.coordinatorEvent(coordinatorEvent) + ) streamVideo.eventNotificationCenter.process(internalEvent) await fulfillment { receivedEvents.count == 1 } @@ -397,7 +399,7 @@ final class StreamVideo_Tests: StreamVideoTestCase, @unchecked Sendable { // When let event = CallAcceptedEvent.dummy() streamVideo.eventNotificationCenter.process( - .coordinatorEvent(.typeCallAcceptedEvent(event)) + WrappedEvent.coordinatorEvent(.typeCallAcceptedEvent(event)) ) await fulfillment { receivedEvents.count == 1 } @@ -434,7 +436,9 @@ final class StreamVideo_Tests: StreamVideoTestCase, @unchecked Sendable { } group.addTask { await self.wait(for: 0.5) - streamVideo.eventNotificationCenter.process(.coordinatorEvent(event)) + streamVideo.eventNotificationCenter.process( + WrappedEvent.coordinatorEvent(event) + ) } } } @@ -468,7 +472,9 @@ final class StreamVideo_Tests: StreamVideoTestCase, @unchecked Sendable { } group.addTask { await self.wait(for: 0.5) - streamVideo.eventNotificationCenter.process(.coordinatorEvent(event)) + streamVideo.eventNotificationCenter.process( + WrappedEvent.coordinatorEvent(event) + ) } } } @@ -506,7 +512,7 @@ final class StreamVideo_Tests: StreamVideoTestCase, @unchecked Sendable { members: [], ownCapabilities: [] ) - let data = try! JSONEncoder.default.encode(getCallResponse) + let data = try! JSONEncoder.streamCore.encode(getCallResponse) httpClient.dataResponses = [data] return httpClient } diff --git a/StreamVideoTests/TestUtils/EventLogger.swift b/StreamVideoTests/TestUtils/EventLogger.swift deleted file mode 100644 index bd11b03f7..000000000 --- a/StreamVideoTests/TestUtils/EventLogger.swift +++ /dev/null @@ -1,36 +0,0 @@ -// -// Copyright © 2026 Stream.io Inc. All rights reserved. -// - -import Foundation -@testable import StreamVideo - -/// The type can be used to check the events published by `NotificationCenter` -final class EventLogger { - @Atomic var events: [Event] = [] - var equatableEvents: [EquatableEvent] { events.map(EquatableEvent.init) } - - init(_ notificationCenter: NotificationCenter) { - notificationCenter.addObserver( - self, - selector: #selector(handleNewEvent), - name: .NewEventReceived, - object: nil - ) - } - - @objc - func handleNewEvent(_ notification: Notification) { - guard let event = notification.event else { - return - } - switch event { - case let .internalEvent(event): - events.append(event) - case let .coordinatorEvent(event): - events.append(event) - case .sfuEvent: - break - } - } -} diff --git a/StreamVideoTests/TestUtils/VirtualTime/VirtualTime.swift b/StreamVideoTests/TestUtils/VirtualTime/VirtualTime.swift index 8b350f1db..0dd871f49 100644 --- a/StreamVideoTests/TestUtils/VirtualTime/VirtualTime.swift +++ b/StreamVideoTests/TestUtils/VirtualTime/VirtualTime.swift @@ -3,6 +3,7 @@ // import Foundation +import StreamCore @testable import StreamVideo import XCTest @@ -10,8 +11,8 @@ import XCTest final class VirtualTime { typealias Seconds = TimeInterval - var scheduledTimers: [VirtualTime.TimerControl] = [] - var timestampToFiredTimers: [TimeInterval: [VirtualTime.TimerControl]] = [:] + var scheduledTimers: [VirtualTime.VirtualTimerControl] = [] + var timestampToFiredTimers: [TimeInterval: [VirtualTime.VirtualTimerControl]] = [:] var currentTime: Seconds @@ -73,8 +74,12 @@ final class VirtualTime { } } - func scheduleTimer(interval: TimeInterval, repeating: Bool, callback: @escaping (TimerControl) -> Void) -> TimerControl { - let timer = TimerControl( + func scheduleTimer( + interval: TimeInterval, + repeating: Bool, + callback: @escaping (VirtualTimerControl) -> Void + ) -> VirtualTimerControl { + let timer = VirtualTimerControl( scheduledFireTime: currentTime + interval, repeatingPeriod: repeating ? interval : 0, callback: callback @@ -91,19 +96,23 @@ final class VirtualTime { extension VirtualTime { /// Internal representation of a timer scheduled with `VirtualTime`. Not meant to be used directly. - class TimerControl: RepeatingTimerControl { + class VirtualTimerControl: TimerControl, RepeatingTimerControl { private(set) var isActive = true var isRunning: Bool { isActive } var repeatingPeriod: TimeInterval var scheduledFireTime: TimeInterval - var callback: (TimerControl) -> Void + var callback: (VirtualTimerControl) -> Void var isRepeated: Bool { repeatingPeriod > 0 } - init(scheduledFireTime: TimeInterval, repeatingPeriod: TimeInterval, callback: @escaping (TimerControl) -> Void) { + init( + scheduledFireTime: TimeInterval, + repeatingPeriod: TimeInterval, + callback: @escaping (VirtualTimerControl) -> Void + ) { self.repeatingPeriod = repeatingPeriod self.scheduledFireTime = scheduledFireTime self.callback = callback diff --git a/StreamVideoTests/TestUtils/VirtualTime/VirtualTimer.swift b/StreamVideoTests/TestUtils/VirtualTime/VirtualTimer.swift index c62287507..6b2609290 100644 --- a/StreamVideoTests/TestUtils/VirtualTime/VirtualTimer.swift +++ b/StreamVideoTests/TestUtils/VirtualTime/VirtualTimer.swift @@ -4,10 +4,10 @@ import Combine import Foundation +import StreamCore @testable import StreamVideo -import protocol StreamVideo.Timer -struct VirtualTimeTimer: Timer { +struct VirtualTimeTimer: TimerScheduling { nonisolated(unsafe) static var time: VirtualTime! static func invalidate() { diff --git a/StreamVideoTests/TestUtils/WebSocketPingController_Delegate.swift b/StreamVideoTests/TestUtils/WebSocketPingController_Delegate.swift deleted file mode 100644 index 0ac9ebcaa..000000000 --- a/StreamVideoTests/TestUtils/WebSocketPingController_Delegate.swift +++ /dev/null @@ -1,25 +0,0 @@ -// -// Copyright © 2026 Stream.io Inc. All rights reserved. -// - -import Foundation -@testable import StreamVideo - -// A concrete `WebSocketPingControllerDelegate` implementation allowing capturing the delegate calls -final class WebSocketPingController_Delegate: WebSocketPingControllerDelegate { - - var sendPing_calledCount = 0 - var disconnectOnNoPongReceived_calledCount = 0 - - func sendPing(healthCheckEvent: SendableEvent) { - sendPing_calledCount += 1 - } - - func sendPing() { - sendPing_calledCount += 1 - } - - func disconnectOnNoPongReceived() { - disconnectOnNoPongReceived_calledCount += 1 - } -} diff --git a/StreamVideoTests/Utils/ClientEventReporting/ClientEventScenarioSupport.swift b/StreamVideoTests/Utils/ClientEventReporting/ClientEventScenarioSupport.swift index 5b55b8603..ee25a1771 100644 --- a/StreamVideoTests/Utils/ClientEventReporting/ClientEventScenarioSupport.swift +++ b/StreamVideoTests/Utils/ClientEventReporting/ClientEventScenarioSupport.swift @@ -85,7 +85,7 @@ final class ClientEventScenarioHarness: @unchecked Sendable { } func sendJoinResponse(_ response: Stream_Video_Sfu_Event_JoinResponse = .init()) { - stack.sfuStack.receiveEvent(.sfuEvent(.joinResponse(response))) + stack.sfuStack.receiveEvent(.joinResponse(response)) } } diff --git a/StreamVideoTests/Utils/ClientEventReporting/ReconnectionClientEventScenarios_Tests.swift b/StreamVideoTests/Utils/ClientEventReporting/ReconnectionClientEventScenarios_Tests.swift index 04dc0de9d..0a0851b6a 100644 --- a/StreamVideoTests/Utils/ClientEventReporting/ReconnectionClientEventScenarios_Tests.swift +++ b/StreamVideoTests/Utils/ClientEventReporting/ReconnectionClientEventScenarios_Tests.swift @@ -15,12 +15,10 @@ final class ReconnectionClientEventScenarios_Tests: XCTestCase, @unchecked Senda expectedTarget: .disconnected, trigger: { harness.stack.sfuStack.receiveEvent( - .sfuEvent( - .error( - SFUEventFactory.error( - code: .internalServerError, - reconnectStrategy: .fast - ) + .error( + SFUEventFactory.error( + code: .internalServerError, + reconnectStrategy: .fast ) ) ) @@ -45,7 +43,7 @@ final class ReconnectionClientEventScenarios_Tests: XCTestCase, @unchecked Senda reconnectStrategy: .rejoin ) harness.stack.sfuStack.receiveEvent( - .sfuEvent(.error(error)) + .error(error) ) harness.stack.sfuStack.setConnectionState( to: .disconnected( @@ -66,12 +64,10 @@ final class ReconnectionClientEventScenarios_Tests: XCTestCase, @unchecked Senda expectedTarget: .disconnected, trigger: { harness.stack.sfuStack.receiveEvent( - .sfuEvent( - .error( - SFUEventFactory.error( - code: .internalServerError, - reconnectStrategy: .migrate - ) + .error( + SFUEventFactory.error( + code: .internalServerError, + reconnectStrategy: .migrate ) ) ) @@ -89,7 +85,7 @@ final class ReconnectionClientEventScenarios_Tests: XCTestCase, @unchecked Senda expectedTarget: .disconnected, trigger: { harness.stack.sfuStack.receiveEvent( - .sfuEvent(.goAway(SFUEventFactory.goAway())) + .goAway(SFUEventFactory.goAway()) ) } ) { target in @@ -105,12 +101,10 @@ final class ReconnectionClientEventScenarios_Tests: XCTestCase, @unchecked Senda expectedTarget: .leaving, trigger: { harness.stack.sfuStack.receiveEvent( - .sfuEvent( - .error( - SFUEventFactory.error( - code: .internalServerError, - reconnectStrategy: .disconnect - ) + .error( + SFUEventFactory.error( + code: .internalServerError, + reconnectStrategy: .disconnect ) ) ) diff --git a/StreamVideoTests/Utils/EventBatcher_Tests.swift b/StreamVideoTests/Utils/EventBatcher_Tests.swift deleted file mode 100644 index ea6afca85..000000000 --- a/StreamVideoTests/Utils/EventBatcher_Tests.swift +++ /dev/null @@ -1,109 +0,0 @@ -// -// Copyright © 2026 Stream.io Inc. All rights reserved. -// - -@testable import StreamVideo -import XCTest - -final class Batch_Tests: XCTestCase, @unchecked Sendable { - var time: VirtualTime { VirtualTimeTimer.time } - - override func setUp() { - super.setUp() - - VirtualTimeTimer.time = .init() - } - - override func tearDown() { - VirtualTimeTimer.invalidate() - - super.tearDown() - } - - func test_append() { - // Create batcher for test events and keep track of handler calls - nonisolated(unsafe) var handlerCalls = [[TestEvent]]() - let batcher = Batcher(period: 10, timerType: VirtualTimeTimer.self) { events, _ in - handlerCalls.append(events) - } - - // Prepare some batches of events - let event1 = TestEvent() - let event2 = TestEvent() - - // Add 1st event to batch - batcher.append(event1) - - // Wait a bit less then period - time.run(numberOfSeconds: 1) - // Assert current batch contains expected values - XCTAssertEqual(batcher.currentBatch, [event1]) - // Assert handler is not called yet - XCTAssertEqual(handlerCalls, []) - - // Add 2nd event to batch - batcher.append(event2) - - // Wait a bit less then period - time.run(numberOfSeconds: 1) - // Assert current batch contains expected values - XCTAssertEqual(batcher.currentBatch, [event1, event2]) - // Assert handler is not called yet - XCTAssertEqual(handlerCalls, []) - - // Wait another bit so batch period has passed - time.run(numberOfSeconds: 10) - // Assert handler is called a single time with batched events - XCTAssertEqual(handlerCalls, [[event1, event2]]) - // Assert current batch is empty - XCTAssertTrue(batcher.currentBatch.isEmpty) - - // Clear handler - handlerCalls.removeAll() - - // Wait another bit so another batch period has passed - time.run(numberOfSeconds: 20) - - // Assert handler is not fired again - XCTAssertTrue(handlerCalls.isEmpty) - } - - func test_processImmidiately() { - // Create batcher with long period and keep track of handler calls - nonisolated(unsafe) var handlerCalls = [[TestEvent]]() - nonisolated(unsafe) var handlerCompletion: (() -> Void)? - let batcher = Batcher(period: 20, timerType: VirtualTimeTimer.self) { events, completion in - handlerCalls.append(events) - handlerCompletion = completion - } - - // Prepare the event - let event = TestEvent() - - // Append the event - batcher.append(event) - - // Ask to process immidiately - let expectation = expectation(description: "`processImmediately` completion") - batcher.processImmediately { - expectation.fulfill() - } - - // Wait for a small bit of time much less then a period - time.run(numberOfSeconds: 0.1) - - // Assert handler is called sooner - XCTAssertEqual(handlerCalls, [[event]]) - - // Complete batch processing - handlerCompletion?() - - // Assert current batch is empty - XCTAssertEqual(batcher.currentBatch, []) - wait(for: [expectation], timeout: defaultTimeout) - } -} - -struct TestEvent: Event, Equatable { - let uuid: UUID = .init() -} diff --git a/StreamVideoTests/Utils/Extensions/Concurrency/Task_DisposableBag_Tests.swift b/StreamVideoTests/Utils/Extensions/Concurrency/Task_DisposableBag_Tests.swift new file mode 100644 index 000000000..4fe37f0ee --- /dev/null +++ b/StreamVideoTests/Utils/Extensions/Concurrency/Task_DisposableBag_Tests.swift @@ -0,0 +1,77 @@ +// +// Copyright © 2026 Stream.io Inc. All rights reserved. +// + +@testable import StreamVideo +import XCTest + +final class Task_DisposableBag_Tests: XCTestCase, @unchecked Sendable { + + func test_init_taskCompletes_removesTaskFromBag() async { + let subject = DisposableBag() + let (stream, continuation) = AsyncStream.makeStream() + let task = Task(disposableBag: subject) { + for await _ in stream { + return + } + } + + XCTAssertFalse(subject.isEmpty) + + continuation.yield() + continuation.finish() + await task.value + + XCTAssertTrue(subject.isEmpty) + } + + func test_init_removeAll_cancelsTask() async { + let subject = DisposableBag() + let (stream, continuation) = AsyncStream.makeStream() + let task = Task(disposableBag: subject) { + for await _ in stream {} + } + + subject.removeAll() + + XCTAssertTrue(task.isCancelled) + continuation.finish() + await task.value + XCTAssertTrue(subject.isEmpty) + } + + func test_init_throwingTaskCompletes_removesTaskFromBag() async throws { + let subject = DisposableBag() + let (stream, continuation) = AsyncStream.makeStream() + let task = Task(disposableBag: subject) { () async throws -> Int in + for await _ in stream { + return 42 + } + throw CancellationError() + } + + XCTAssertFalse(subject.isEmpty) + continuation.yield() + continuation.finish() + let value = try await task.value + + XCTAssertEqual(value, 42) + XCTAssertTrue(subject.isEmpty) + } + + func test_store_withoutIdentifier_removeAllCancelsTask() async { + let subject = DisposableBag() + let (stream, continuation) = AsyncStream.makeStream() + let task = Task { + for await _ in stream {} + } + task.store(in: subject) + + subject.removeAll() + + XCTAssertTrue(task.isCancelled) + continuation.finish() + await task.value + XCTAssertTrue(subject.isEmpty) + } +} diff --git a/StreamVideoTests/Utils/Logger/LoggerConcurrency_Tests.swift b/StreamVideoTests/Utils/Logger/LoggerConcurrency_Tests.swift index 53ad64239..6e633ee57 100644 --- a/StreamVideoTests/Utils/Logger/LoggerConcurrency_Tests.swift +++ b/StreamVideoTests/Utils/Logger/LoggerConcurrency_Tests.swift @@ -3,10 +3,49 @@ // @testable import StreamVideo +import StreamWebRTC import XCTest final class LoggerConcurrency_Tests: XCTestCase, @unchecked Sendable { + func test_webRTCLogsEnabled_updatesWebRTCMode() { + let originalMode = Logger.WebRTC.mode + defer { Logger.WebRTC.mode = originalMode } + + LogConfig.webRTCLogsEnabled = false + + XCTAssertFalse(LogConfig.webRTCLogsEnabled) + guard case .none = Logger.WebRTC.mode else { + return XCTFail("Expected WebRTC logging to be disabled.") + } + + LogConfig.webRTCLogsEnabled = true + + XCTAssertTrue(LogConfig.webRTCLogsEnabled) + guard case .all = Logger.WebRTC.mode else { + return XCTFail("Expected WebRTC logging to be enabled.") + } + } + + func test_logLevelChanged_afterWebRTCLoggerInitialization_updatesExpectedSeverity() { + let originalLevel = LogConfig.level + defer { LogConfig.level = originalLevel } + + _ = Logger.WebRTC.RTCLogger.default + + let cases: [(LogLevel, RTCLoggingSeverity)] = [ + (.debug, .verbose), + (.info, .info), + (.warning, .warning), + (.error, .error) + ] + + for (level, expectedSeverity) in cases { + LogConfig.level = level + XCTAssertEqual(Logger.WebRTC.severity, expectedSeverity) + } + } + func test_concurrentLoggingProcessesAllMessages() { let iterations = 200 let expectation = expectation(description: "All log messages are processed") diff --git a/StreamVideoTests/Utils/RawJSON_Tests.swift b/StreamVideoTests/Utils/RawJSON_Tests.swift index b2c221235..cbb101dbd 100644 --- a/StreamVideoTests/Utils/RawJSON_Tests.swift +++ b/StreamVideoTests/Utils/RawJSON_Tests.swift @@ -2,6 +2,7 @@ // Copyright © 2026 Stream.io Inc. All rights reserved. // +import Foundation @testable import StreamVideo import XCTest @@ -24,7 +25,7 @@ final class RawJSON_Tests: XCTestCase, @unchecked Sendable { ] for test in tests { - let encoded = try JSONEncoder.stream.encode(test.value) + let encoded = try JSONEncoder.streamCore.encode(test.value) AssertJSONEqual(encoded, test.expected.data(using: .utf8)!) } } @@ -46,7 +47,7 @@ final class RawJSON_Tests: XCTestCase, @unchecked Sendable { ] for test in tests { - let rawJSON = try? JSONDecoder.stream.decode(RawJSON.self, from: test.value.data(using: .utf8)!) + let rawJSON = try? JSONDecoder.streamCore.decode(RawJSON.self, from: test.value.data(using: .utf8)!) XCTAssertEqual(rawJSON, test.expected) } } @@ -244,4 +245,59 @@ final class RawJSON_Tests: XCTestCase, @unchecked Sendable { XCTAssertEqual(rawJSONArray, .array([.string("Hello"), .string("Stream")])) } + + func test_value_preservesLegacyNumericConversions() { + let subject = RawJSON.number(42.5) + let intValue: Int? = subject.value() + let int32Value: Int32? = subject.value() + let int64Value: Int64? = subject.value() + let uintValue: UInt? = subject.value() + let uint32Value: UInt32? = subject.value() + let uint64Value: UInt64? = subject.value() + let doubleValue: Double? = subject.value() + let floatValue: Float? = subject.value() + + XCTAssertEqual(intValue, 42) + XCTAssertEqual(int32Value, 42) + XCTAssertEqual(int64Value, 42) + XCTAssertEqual(uintValue, 42) + XCTAssertEqual(uint32Value, 42) + XCTAssertEqual(uint64Value, 42) + XCTAssertEqual(doubleValue, 42.5) + XCTAssertEqual(floatValue, 42.5) + } + + func test_value_preservesLegacyStructuredValuesAndFallback() { + let dictionary: [String: RawJSON]? = + RawJSON.dictionary(["key": .string("value")]).value() + let array: [RawJSON]? = + RawJSON.array([.number(1), .number(2)]).value() + let string: String? = RawJSON.string("value").value() + let bool: Bool? = RawJSON.bool(true).value() + let missing: String? = RawJSON.nil.value() + + XCTAssertEqual(dictionary, ["key": .string("value")]) + XCTAssertEqual(array, [.number(1), .number(2)]) + XCTAssertEqual(string, "value") + XCTAssertEqual(bool, true) + XCTAssertNil(missing) + XCTAssertEqual(RawJSON.bool(true).value(fallback: "fallback"), "fallback") + } + + func test_init_nsObject_preservesLegacyRecursiveConversion() { + let subject = RawJSON( + [ + "name": NSString(string: "stream"), + "values": NSArray(array: [NSNumber(value: 1), NSNumber(value: 2)]) + ] as NSDictionary + ) + + XCTAssertEqual( + subject, + .dictionary([ + "name": .string("stream"), + "values": .array([.number(1), .number(2)]) + ]) + ) + } } diff --git a/StreamVideoTests/Utils/RepeatingTimer_Tests.swift b/StreamVideoTests/Utils/RepeatingTimer_Tests.swift index a349de041..9eec7db8a 100644 --- a/StreamVideoTests/Utils/RepeatingTimer_Tests.swift +++ b/StreamVideoTests/Utils/RepeatingTimer_Tests.swift @@ -2,6 +2,7 @@ // Copyright © 2026 Stream.io Inc. All rights reserved. // +import StreamCore @testable import StreamVideo import XCTest diff --git a/StreamVideoTests/Utils/Timers/TimerPublisher_Tests.swift b/StreamVideoTests/Utils/Timers/TimerPublisher_Tests.swift index e72dad1a9..fc24bcfd5 100644 --- a/StreamVideoTests/Utils/Timers/TimerPublisher_Tests.swift +++ b/StreamVideoTests/Utils/Timers/TimerPublisher_Tests.swift @@ -3,6 +3,7 @@ // import Combine +import StreamCore @testable @preconcurrency import StreamVideo import XCTest @@ -16,7 +17,7 @@ final class TimerPublisher_Tests: XCTestCase, @unchecked Sendable { func test_receive_whenSubscribed_emitsDates() async { let expectation = expectation(description: "Should emit at least one value") - let subject = TimerPublisher(interval: 0.2) + let subject = DefaultTimer.publish(every: 0.2) subject .prefix(3) @@ -34,7 +35,7 @@ final class TimerPublisher_Tests: XCTestCase, @unchecked Sendable { // MARK: - Suspends timer when all subscriptions are cancelled func test_receive_whenSubscriptionCancelled_timerSuspends() async throws { - let subject = TimerPublisher(interval: 0.2) + let subject = DefaultTimer.publish(every: 0.2) let expectation = expectation(description: "Timer should suspend after cancel") @@ -60,7 +61,7 @@ final class TimerPublisher_Tests: XCTestCase, @unchecked Sendable { // MARK: - Resumes timer after new subscription func test_receive_whenResubscribed_timerResumes() async { - let subject = TimerPublisher(interval: 0.2) + let subject = DefaultTimer.publish(every: 0.2) let firstValueExpectation = expectation(description: "Should receive first value") let expectation = expectation(description: "Should receive values after resubscription") diff --git a/StreamVideoTests/WebRTC/SFU/Mocks/MockSFUWebSocket.swift b/StreamVideoTests/WebRTC/SFU/Mocks/MockSFUWebSocket.swift new file mode 100644 index 000000000..2e9a2e0e1 --- /dev/null +++ b/StreamVideoTests/WebRTC/SFU/Mocks/MockSFUWebSocket.swift @@ -0,0 +1,123 @@ +// +// Copyright © 2026 Stream.io Inc. All rights reserved. +// + +import Combine +import Foundation +import StreamCore +@testable import StreamVideo + +final class MockSFUWebSocket: SFUWebSocket, @unchecked Sendable { + enum FunctionKey: Hashable { + case connect + case disconnect + case disconnectAsync + case disconnectForReconfiguration + case send + case inject + } + + private let lock = NSLock() + private let stateSubject = CurrentValueSubject< + WebSocketConnectionState, + Never + >(.initialized) + private let receivedEventSubject = PassthroughSubject< + Stream_Video_Sfu_Event_SfuEvent.OneOf_EventPayload, + Never + >() + private var calls: [FunctionKey: Int] = [:] + private var sentMessages: [any SendableEvent] = [] + + override var connectionState: WebSocketConnectionState { + stateSubject.value + } + + override var connectionStatePublisher: AnyPublisher< + WebSocketConnectionState, + Never + > { + stateSubject.eraseToAnyPublisher() + } + + override var eventPublisher: AnyPublisher< + Stream_Video_Sfu_Event_SfuEvent.OneOf_EventPayload, + Never + > { + receivedEventSubject.eraseToAnyPublisher() + } + + init( + connectURL: URL = URL(string: "https://getstream.io")! + ) { + super.init( + url: connectURL, + sessionConfiguration: .ephemeral + ) + } + + func timesCalled(_ key: FunctionKey) -> Int { + withLock { calls[key, default: 0] } + } + + func recordedInputPayload( + _ type: T.Type, + for key: FunctionKey + ) -> [T]? { + guard key == .send else { return nil } + return withLock { sentMessages.compactMap { $0 as? T } } + } + + func simulate(state: WebSocketConnectionState) { + stateSubject.send(state) + } + + func receive( + _ payload: Stream_Video_Sfu_Event_SfuEvent.OneOf_EventPayload + ) { + inject(payload) + } + + override func connect() { + record(.connect) + stateSubject.send(.connecting) + } + + override func disconnect() async { + record(.disconnectAsync) + stateSubject.send(.disconnecting(source: .userInitiated)) + } + + override func disconnect(code: URLSessionWebSocketTask.CloseCode) { + record(.disconnect) + stateSubject.send(.disconnecting(source: .userInitiated)) + } + + override func disconnectForReconfiguration() { + record(.disconnectForReconfiguration) + stateSubject.send(.disconnecting(source: .userInitiated)) + } + + override func send(_ message: any SendableEvent) { + withLock { + calls[.send, default: 0] += 1 + sentMessages.append(message) + } + } + + override func inject( + _ payload: Stream_Video_Sfu_Event_SfuEvent.OneOf_EventPayload + ) { + receivedEventSubject.send(payload) + } + + private func record(_ key: FunctionKey) { + withLock { calls[key, default: 0] += 1 } + } + + private func withLock(_ action: () -> T) -> T { + lock.lock() + defer { lock.unlock() } + return action() + } +} diff --git a/StreamVideoTests/WebRTC/SFU/Mocks/MockWebSocketClient.swift b/StreamVideoTests/WebRTC/SFU/Mocks/MockWebSocketClient.swift deleted file mode 100644 index d79ab8ebe..000000000 --- a/StreamVideoTests/WebRTC/SFU/Mocks/MockWebSocketClient.swift +++ /dev/null @@ -1,108 +0,0 @@ -// -// Copyright © 2026 Stream.io Inc. All rights reserved. -// - -import Foundation -@testable import StreamVideo - -final class MockWebSocketClient: WebSocketClient, Mockable, @unchecked Sendable { - - // MARK: - Mockable - - typealias FunctionKey = MockFunctionKey - enum MockFunctionKey: Hashable, CaseIterable { - case connect - case disconnect - case disconnectAsync - } - - enum FunctionInput: Payloadable { - case connect - case disconnect( - code: URLSessionWebSocketTask.CloseCode, - source: WebSocketConnectionState.DisconnectionSource, - completion: () -> Void - ) - case disconnectAsync(source: WebSocketConnectionState.DisconnectionSource) - - var payload: Any { - switch self { - case .connect: - return () - case let .disconnect(code, source, completion): - return (code, source, completion) - case let .disconnectAsync(source): - return source - } - } - } - - var stubbedProperty: [String: Any] = [:] - var stubbedFunction: [FunctionKey: Any] = [:] - @Atomic var stubbedFunctionInput: [FunctionKey: [FunctionInput]] = MockFunctionKey - .allCases - .reduce(into: [FunctionKey: [FunctionInput]]()) { $0[$1] = [] } - func stub(for keyPath: KeyPath, with value: T) { - stubbedProperty[propertyKey(for: keyPath)] = value - } - - func stub(for function: FunctionKey, with value: T) { - stubbedFunction[function] = value - } - - // MARK: - Super - - let mockEngine: MockWebSocketEngine = .init() - override var engine: (any WebSocketEngine)? { - get { self[dynamicMember: \.engine] } - set { _ = newValue } - } - - convenience init(webSocketClientType: WebSocketClientType) { - self.init( - sessionConfiguration: .default, - eventDecoder: WebRTCEventDecoder(), - eventNotificationCenter: .init(), - webSocketClientType: webSocketClientType, - connectURL: .init(string: "https://getstream.io")! - ) - stub(for: \.engine, with: mockEngine) - } - - override func connect() { - stubbedFunctionInput[.connect]?.append(.connect) - } - - override func disconnect( - code: URLSessionWebSocketTask.CloseCode = .normalClosure, - source: WebSocketConnectionState.DisconnectionSource = .userInitiated, - completion: @escaping () -> Void - ) { - stubbedFunctionInput[.disconnect]?.append( - .disconnect( - code: code, - source: source, - completion: completion - ) - ) - completion() - } - - override func disconnect( - source: WebSocketConnectionState.DisconnectionSource = .userInitiated - ) async { - stubbedFunctionInput[.disconnectAsync]?.append( - .disconnectAsync(source: source) - ) - } - - // MARK: - Helpers - - func simulate(state: WebSocketConnectionState) { - stub(for: \.connectionState, with: state) - connectionStateDelegate?.webSocketClient( - self, - didUpdateConnectionState: state - ) - } -} diff --git a/StreamVideoTests/WebRTC/SFU/Mocks/MockWebSocketEngine.swift b/StreamVideoTests/WebRTC/SFU/Mocks/MockWebSocketEngine.swift deleted file mode 100644 index ef28ee313..000000000 --- a/StreamVideoTests/WebRTC/SFU/Mocks/MockWebSocketEngine.swift +++ /dev/null @@ -1,99 +0,0 @@ -// -// Copyright © 2026 Stream.io Inc. All rights reserved. -// - -import Foundation -@testable import StreamVideo - -final class MockWebSocketEngine: WebSocketEngine, Mockable, @unchecked Sendable { - typealias FunctionKey = MockFunctionKey - - enum MockFunctionKey: Hashable, CaseIterable { - case connect - case disconnect - case disconnectWithCode - case sendPing - case sendMessage - case sendJSON - } - - var stubbedProperty: [String: Any] = [:] - var stubbedFunction: [MockFunctionKey: Any] = [:] - func stub(for keyPath: KeyPath, with value: T) {} - func stub(for function: MockFunctionKey, with value: T) {} - - enum FunctionInput: Payloadable { - case connect - case disconnect - case disconnectWithCode(URLSessionWebSocketTask.CloseCode) - case sendPing - case sendMessage(message: SendableEvent) - case sendJSON(json: Codable) - - var payload: Any { - switch self { - case .connect: - return () - case .disconnect: - return () - case .sendPing: - return () - case let .sendMessage(message): - return message - case let .sendJSON(json): - return json - case let .disconnectWithCode(closeCode): - return closeCode - } - } - } - - @Atomic var stubbedFunctionInput: [FunctionKey: [FunctionInput]] = MockFunctionKey - .allCases - .reduce(into: [FunctionKey: [FunctionInput]]()) { $0[$1] = [] } - - let request: URLRequest - let callbackQueue: DispatchQueue - weak var delegate: WebSocketEngineDelegate? - - convenience init() { - self.init( - request: .init(url: .init(string: "https://getstream.io")!), - sessionConfiguration: .default, - callbackQueue: .main - ) - } - - init( - request: URLRequest, - sessionConfiguration: URLSessionConfiguration, - callbackQueue: DispatchQueue - ) { - self.request = request - self.callbackQueue = callbackQueue - } - - func connect() { - stubbedFunctionInput[.connect]?.append(.connect) - } - - func disconnect() { - stubbedFunctionInput[.disconnect]?.append(.disconnect) - } - - func disconnect(with code: URLSessionWebSocketTask.CloseCode) { - stubbedFunctionInput[.disconnectWithCode]?.append(.disconnectWithCode(code)) - } - - func send(message: any SendableEvent) { - stubbedFunctionInput[.sendMessage]?.append(.sendMessage(message: message)) - } - - func send(jsonMessage: any Codable) { - stubbedFunctionInput[.sendJSON]?.append(.sendJSON(json: jsonMessage)) - } - - func sendPing() { - stubbedFunctionInput[.sendPing]?.append(.sendPing) - } -} diff --git a/StreamVideoTests/WebRTC/SFU/SFUAdapter_Tests.swift b/StreamVideoTests/WebRTC/SFU/SFUAdapter_Tests.swift index 541c86f5e..35ed01f2c 100644 --- a/StreamVideoTests/WebRTC/SFU/SFUAdapter_Tests.swift +++ b/StreamVideoTests/WebRTC/SFU/SFUAdapter_Tests.swift @@ -2,47 +2,49 @@ // Copyright © 2026 Stream.io Inc. All rights reserved. // +import StreamCore @testable import StreamVideo import XCTest final class SFUAdapterTests: XCTestCase, @unchecked Sendable { + private let connectedState = WebSocketConnectionState + .connected(healthCheckInfo: .init()) private lazy var mockService: MockSignalServer! = .init() - private lazy var mockWebSocket: MockWebSocketClient! = .init(webSocketClientType: .sfu) + private lazy var mockWebSocket: MockSFUWebSocket! = .init() + private lazy var replacementWebSocket: MockSFUWebSocket! = .init() private lazy var subject: SFUAdapter! = .init( signalService: mockService, webSocket: mockWebSocket, - webSocketFactory: MockWebSocketClientFactory() + webSocketFactory: { [replacementWebSocket] _, _, _ in + replacementWebSocket! + } ) // MARK: - Lifecycle override func setUp() { super.setUp() + _ = subject } override func tearDown() { subject = nil mockService = nil mockWebSocket = nil + replacementWebSocket = nil super.tearDown() } - // MARK: - init - - func test_init_webSocketDelegateWasSetCorrectly() { - _ = subject - - XCTAssertTrue(mockWebSocket.connectionStateDelegate === subject) - } - // MARK: - connect - func test_connect_givenValidConfiguration_thenCallsWebSocketConnect() { + func test_connect_givenValidConfiguration_thenCallsWebSocketConnect() async { // When subject.connect() // Then - XCTAssertEqual(mockWebSocket.timesCalled(.connect), 1) + await fulfillment { + self.mockWebSocket.timesCalled(.connect) == 1 + } } func test_connect_eventWasPublished() async throws { @@ -56,7 +58,7 @@ final class SFUAdapterTests: XCTestCase, @unchecked Sendable { func test_disconnect_givenConnectedState_thenCallsWebSocketDisconnect() async { _ = subject - mockWebSocket.simulate(state: .connected(healthCheckInfo: .init())) + mockWebSocket.simulate(state: connectedState) // When await subject.disconnect() @@ -67,7 +69,7 @@ final class SFUAdapterTests: XCTestCase, @unchecked Sendable { func test_disconnect_eventWasPublished() async throws { _ = subject - mockWebSocket.simulate(state: .connected(healthCheckInfo: .init())) + mockWebSocket.simulate(state: connectedState) await assertEventWasPublished( expected: SFUAdapter.DisconnectEvent(hostname: mockService.hostname), @@ -83,9 +85,9 @@ final class SFUAdapterTests: XCTestCase, @unchecked Sendable { // Then let input = try XCTUnwrap( - mockWebSocket.mockEngine.recordedInputPayload( + mockWebSocket.recordedInputPayload( Stream_Video_Sfu_Event_HealthCheckRequest.self, - for: .sendMessage + for: .send ) ) @@ -95,16 +97,16 @@ final class SFUAdapterTests: XCTestCase, @unchecked Sendable { // MARK: - sendMessage func test_sendMessage_webSocketEngineWasCalled() throws { - mockWebSocket.simulate(state: .connected(healthCheckInfo: .init())) + mockWebSocket.simulate(state: connectedState) // When - subject.send(message: Stream_Video_Sfu_Event_HealthCheckRequest()) + subject.send(message: Stream_Video_Sfu_Event_SfuRequest()) // Then let input = try XCTUnwrap( - mockWebSocket.mockEngine.recordedInputPayload( - Stream_Video_Sfu_Event_HealthCheckRequest.self, - for: .sendMessage + mockWebSocket.recordedInputPayload( + Stream_Video_Sfu_Event_SfuRequest.self, + for: .send ) ) @@ -113,43 +115,99 @@ final class SFUAdapterTests: XCTestCase, @unchecked Sendable { // MARK: - refresh - func test_refresh_currentWebSocketDisconnects() { + func test_refresh_currentWebSocketDisconnects() async { subject.refresh( webSocketConfiguration: .init( - url: .init(string: "https://getstream.io")!, - eventNotificationCenter: .init() + url: .init(string: "https://getstream.io")! ) ) - XCTAssertEqual(mockWebSocket.timesCalled(.disconnect), 1) + await fulfillment { + self.mockWebSocket + .timesCalled(.disconnectForReconfiguration) == 1 + } } func test_refresh_oldWebSocketDisconnectsNoLongerReceivesCalls() throws { - mockWebSocket.simulate(state: .connected(healthCheckInfo: .init())) + mockWebSocket.simulate(state: connectedState) subject.refresh( webSocketConfiguration: .init( - url: .init(string: "https://getstream.io")!, - eventNotificationCenter: .init() + url: .init(string: "https://getstream.io")! ) ) subject.sendHealthCheck() let input = try XCTUnwrap( - mockWebSocket.mockEngine.recordedInputPayload( + mockWebSocket.recordedInputPayload( Stream_Video_Sfu_Event_HealthCheckRequest.self, - for: .sendMessage + for: .send ) ) XCTAssertNil(input.first) } + func test_refresh_lateOldWebSocketState_isIgnored() async { + _ = subject + mockWebSocket.simulate(state: connectedState) + + subject.refresh( + webSocketConfiguration: .init( + url: .init(string: "https://getstream.io")! + ) + ) + mockWebSocket.simulate( + state: .disconnected(source: .systemInitiated) + ) + + XCTAssertEqual(subject.connectionState, .initialized) + + subject.connect() + + await fulfillment { + self.subject.connectionState == .connecting + } + } + + func test_refresh_existingEventPublisher_ignoresLateOldSocketEventAndReceivesNewSocketEvent() async { + _ = subject + var received: [Stream_Video_Sfu_Event_SfuEvent.OneOf_EventPayload] = [] + let newEventReceived = expectation(description: "New socket event received.") + let cancellable = subject.publisher.sink { + received.append($0) + newEventReceived.fulfill() + } + defer { cancellable.cancel() } + + subject.refresh( + webSocketConfiguration: .init( + url: .init(string: "https://getstream.io")! + ) + ) + subject.connect() + + let oldEvent = + Stream_Video_Sfu_Event_SfuEvent.OneOf_EventPayload + .healthCheckResponse(.init()) + var offer = Stream_Video_Sfu_Event_SubscriberOffer() + offer.sdp = .unique + let newEvent = + Stream_Video_Sfu_Event_SfuEvent.OneOf_EventPayload + .subscriberOffer(offer) + + mockWebSocket.receive(oldEvent) + replacementWebSocket.receive(newEvent) + + await fulfillment(of: [newEventReceived], timeout: defaultTimeout) + XCTAssertEqual(received, [newEvent]) + } + // MARK: - updateTrackMuteState func test_updateTrackMuteState_serviceWasCalledWithCorrectRequest() async throws { - mockWebSocket.simulate(state: .connected(healthCheckInfo: .init())) + mockWebSocket.simulate(state: connectedState) let sessionID = String.unique try await subject.updateTrackMuteState( @@ -169,7 +227,7 @@ final class SFUAdapterTests: XCTestCase, @unchecked Sendable { func test_updateTrackMuteState_eventWasPublished() async throws { _ = subject - mockWebSocket.simulate(state: .connected(healthCheckInfo: .init())) + mockWebSocket.simulate(state: connectedState) let sessionID = String.unique var payload = Stream_Video_Sfu_Signal_UpdateMuteStatesRequest() payload.sessionID = sessionID @@ -196,7 +254,7 @@ final class SFUAdapterTests: XCTestCase, @unchecked Sendable { // MARK: - sendStats func test_sendStats_withoutThermalState_serviceWasCalledWithCorrectRequest() async throws { - mockWebSocket.simulate(state: .connected(healthCheckInfo: .init())) + mockWebSocket.simulate(state: connectedState) let sessionID = String.unique let unifiedSessionId = String.unique @@ -216,7 +274,7 @@ final class SFUAdapterTests: XCTestCase, @unchecked Sendable { } func test_sendStats_withThermalState_serviceWasCalledWithCorrectRequest() async throws { - mockWebSocket.simulate(state: .connected(healthCheckInfo: .init())) + mockWebSocket.simulate(state: connectedState) let sessionID = String.unique let unifiedSessionId = String.unique @@ -239,7 +297,7 @@ final class SFUAdapterTests: XCTestCase, @unchecked Sendable { // MARK: - toggleNoiseCancellation func test_toggleNoiseCancellation_enabled_serviceWasCalledWithCorrectRequest() async throws { - mockWebSocket.simulate(state: .connected(healthCheckInfo: .init())) + mockWebSocket.simulate(state: connectedState) let sessionID = String.unique // When @@ -252,7 +310,7 @@ final class SFUAdapterTests: XCTestCase, @unchecked Sendable { func test_toggleNoiseCancellation_enabled_eventWasPublished() async throws { _ = subject - mockWebSocket.simulate(state: .connected(healthCheckInfo: .init())) + mockWebSocket.simulate(state: connectedState) let sessionID = String.unique var request = Stream_Video_Sfu_Signal_StartNoiseCancellationRequest() request.sessionID = sessionID @@ -269,7 +327,7 @@ final class SFUAdapterTests: XCTestCase, @unchecked Sendable { } func test_toggleNoiseCancellation_disabled_serviceWasCalledWithCorrectRequest() async throws { - mockWebSocket.simulate(state: .connected(healthCheckInfo: .init())) + mockWebSocket.simulate(state: connectedState) let sessionID = String.unique // When @@ -282,7 +340,7 @@ final class SFUAdapterTests: XCTestCase, @unchecked Sendable { func test_toggleNoiseCancellation_disabled_eventWasPublished() async throws { _ = subject - mockWebSocket.simulate(state: .connected(healthCheckInfo: .init())) + mockWebSocket.simulate(state: connectedState) let sessionID = String.unique var request = Stream_Video_Sfu_Signal_StopNoiseCancellationRequest() request.sessionID = sessionID @@ -302,7 +360,7 @@ final class SFUAdapterTests: XCTestCase, @unchecked Sendable { func test_setPublisher_serviceWasCalledWithCorrectRequest() async throws { _ = subject - mockWebSocket.simulate(state: .connected(healthCheckInfo: .init())) + mockWebSocket.simulate(state: connectedState) let sessionDescription = String.unique let sessionID = String.unique @@ -321,7 +379,7 @@ final class SFUAdapterTests: XCTestCase, @unchecked Sendable { func test_setPublisher_eventWasPublished() async throws { _ = subject - mockWebSocket.simulate(state: .connected(healthCheckInfo: .init())) + mockWebSocket.simulate(state: connectedState) let sessionID = String.unique let sessionDescription = String.unique var request = Stream_Video_Sfu_Signal_SetPublisherRequest() @@ -347,7 +405,7 @@ final class SFUAdapterTests: XCTestCase, @unchecked Sendable { // MARK: - updateSubscriptions func test_updateSubscriptions_serviceWasCalledWithCorrectRequest() async throws { - mockWebSocket.simulate(state: .connected(healthCheckInfo: .init())) + mockWebSocket.simulate(state: connectedState) let sessionID = String.unique // When @@ -363,7 +421,7 @@ final class SFUAdapterTests: XCTestCase, @unchecked Sendable { func test_updateSubscriptions_eventWasPublished() async throws { _ = subject - mockWebSocket.simulate(state: .connected(healthCheckInfo: .init())) + mockWebSocket.simulate(state: connectedState) let sessionID = String.unique var request = Stream_Video_Sfu_Signal_UpdateSubscriptionsRequest() request.sessionID = sessionID @@ -404,7 +462,7 @@ final class SFUAdapterTests: XCTestCase, @unchecked Sendable { func test_sendAnswer_eventWasPublished() async throws { _ = subject - mockWebSocket.simulate(state: .connected(healthCheckInfo: .init())) + mockWebSocket.simulate(state: connectedState) let sessionDescription = String.unique let sessionID = String.unique var request = Stream_Video_Sfu_Signal_SendAnswerRequest() @@ -449,7 +507,7 @@ final class SFUAdapterTests: XCTestCase, @unchecked Sendable { func test_iCETrickle_eventWasPublished() async throws { _ = subject - mockWebSocket.simulate(state: .connected(healthCheckInfo: .init())) + mockWebSocket.simulate(state: connectedState) let candidate = String.unique let sessionID = String.unique var request = Stream_Video_Sfu_Models_ICETrickle() @@ -491,7 +549,7 @@ final class SFUAdapterTests: XCTestCase, @unchecked Sendable { func test_restartICE_eventWasPublished() async throws { _ = subject - mockWebSocket.simulate(state: .connected(healthCheckInfo: .init())) + mockWebSocket.simulate(state: connectedState) let sessionID = String.unique var request = Stream_Video_Sfu_Signal_ICERestartRequest() request.sessionID = sessionID @@ -515,7 +573,7 @@ final class SFUAdapterTests: XCTestCase, @unchecked Sendable { func test_sendJoinRequest_eventWasPublished() async throws { _ = subject - mockWebSocket.simulate(state: .connected(healthCheckInfo: .init())) + mockWebSocket.simulate(state: connectedState) var payload = Stream_Video_Sfu_Event_JoinRequest() payload.sessionID = .unique @@ -529,7 +587,7 @@ final class SFUAdapterTests: XCTestCase, @unchecked Sendable { func test_sendLeaveRequest_eventWasPublished() async throws { _ = subject - mockWebSocket.simulate(state: .connected(healthCheckInfo: .init())) + mockWebSocket.simulate(state: connectedState) var payload = Stream_Video_Sfu_Event_LeaveCallRequest() payload.sessionID = .unique @@ -578,9 +636,9 @@ final class SFUAdapterTests: XCTestCase, @unchecked Sendable { healthCheckCancellable.cancel() } - mockWebSocket.eventSubject.send(.sfuEvent(.subscriberOffer(offer))) - mockWebSocket.eventSubject.send(.sfuEvent(.iceTrickle(trickle))) - mockWebSocket.eventSubject.send(.sfuEvent(.healthCheckResponse(healthCheck))) + mockWebSocket.receive(.subscriberOffer(offer)) + mockWebSocket.receive(.iceTrickle(trickle)) + mockWebSocket.receive(.healthCheckResponse(healthCheck)) await wait(for: 0.1) @@ -616,18 +674,20 @@ final class SFUAdapterTests: XCTestCase, @unchecked Sendable { XCTAssertTrue(replayedHealthChecks.isEmpty) } - // MARK: - webSocketClient(_:didUpdateConnectionState:) + // MARK: - connectionState - func test_didUpdateConnectionState_connectionStateWasUpdated() { - [ - WebSocketConnectionState.initialized, + func test_connectionState_mirrorsWebSocketConnectionState() { + let states: [WebSocketConnectionState] = [ + .initialized, .disconnected(source: .systemInitiated), .connecting, .authenticating, - .connected(healthCheckInfo: .init()), + connectedState, .disconnecting(source: .userInitiated) - ].forEach { state in - subject.webSocketClient(mockWebSocket, didUpdateConnectionState: state) + ] + + states.forEach { state in + mockWebSocket.simulate(state: state) XCTAssertEqual(subject.connectionState, state) } diff --git a/StreamVideoTests/WebRTC/SFU/SFUEventAdapter_Tests.swift b/StreamVideoTests/WebRTC/SFU/SFUEventAdapter_Tests.swift index 1505cd9c5..36bd0ebb9 100644 --- a/StreamVideoTests/WebRTC/SFU/SFUEventAdapter_Tests.swift +++ b/StreamVideoTests/WebRTC/SFU/SFUEventAdapter_Tests.swift @@ -12,11 +12,10 @@ final class SFUEventAdapter_Tests: XCTestCase, @unchecked Sendable { private nonisolated(unsafe) static var videoConfig: VideoConfig! = .dummy() private lazy var mockService: MockSignalServer! = .init() - private lazy var mockWebSocket: MockWebSocketClient! = .init(webSocketClientType: .sfu) + private lazy var mockWebSocket: MockSFUWebSocket! = .init() private lazy var sfuAdapter: SFUAdapter! = .init( signalService: mockService, - webSocket: mockWebSocket, - webSocketFactory: MockWebSocketClientFactory() + webSocket: mockWebSocket ) private lazy var stageSubject: CurrentValueSubject< WebRTCCoordinator.StateMachine.Stage.ID, @@ -86,7 +85,7 @@ final class SFUEventAdapter_Tests: XCTestCase, @unchecked Sendable { try await assert( event, - wrappedEvent: .sfuEvent(.connectionQualityChanged(event)), + payload: .connectionQualityChanged(event), initialState: [participantA, participantB].reduce(into: [String: CallParticipant]()) { $0[$1.sessionId] = $1 } ) { $0[participantA.sessionId]?.connectionQuality == .good && $0[participantB.sessionId]?.connectionQuality == .excellent @@ -111,7 +110,7 @@ final class SFUEventAdapter_Tests: XCTestCase, @unchecked Sendable { try await assert( event, - wrappedEvent: .sfuEvent(.audioLevelChanged(event)), + payload: .audioLevelChanged(event), initialState: [participantA, participantB].reduce(into: [String: CallParticipant]()) { $0[$1.sessionId] = $1 } ) { $0[participantA.sessionId]?.audioLevel == 0.8 && $0[participantB.sessionId]?.audioLevel == 0 @@ -136,7 +135,7 @@ final class SFUEventAdapter_Tests: XCTestCase, @unchecked Sendable { try await assert( event, - wrappedEvent: .sfuEvent(.changePublishQuality(event)), + payload: .changePublishQuality(event), initialState: [participantA, participantB].reduce(into: [String: CallParticipant]()) { $0[$1.sessionId] = $1 } ) { [event] _ in let mockPublisher = try XCTUnwrap(publisher as? MockRTCPeerConnectionCoordinator) @@ -158,7 +157,7 @@ final class SFUEventAdapter_Tests: XCTestCase, @unchecked Sendable { try await assert( event, - wrappedEvent: .sfuEvent(.participantJoined(event)), + payload: .participantJoined(event), initialState: [.dummy()].reduce(into: [String: CallParticipant]()) { $0[$1.sessionId] = $1 } ) { $0[participant.sessionId] != nil && $0[participant.sessionId]?.showTrack == true @@ -176,7 +175,7 @@ final class SFUEventAdapter_Tests: XCTestCase, @unchecked Sendable { try await assert( event, - wrappedEvent: .sfuEvent(.participantJoined(event)), + payload: .participantJoined(event), initialState: [.dummy()].reduce(into: [String: CallParticipant]()) { $0[$1.sessionId] = $1 } ) { guard let pin = $0[participant.sessionId]?.pin else { @@ -199,7 +198,7 @@ final class SFUEventAdapter_Tests: XCTestCase, @unchecked Sendable { try await assert( event, - wrappedEvent: .sfuEvent(.participantJoined(event)), + payload: .participantJoined(event), initialState: [.dummy()].reduce(into: [String: CallParticipant]()) { $0[$1.sessionId] = $1 } ) { $0[participant.sessionId] != nil @@ -218,7 +217,7 @@ final class SFUEventAdapter_Tests: XCTestCase, @unchecked Sendable { try await assert( joinedEvent, - wrappedEvent: .sfuEvent(.participantJoined(joinedEvent)), + payload: .participantJoined(joinedEvent), initialState: [:] ) { $0[participant.sessionId]?.pin?.isLocal == false @@ -236,7 +235,7 @@ final class SFUEventAdapter_Tests: XCTestCase, @unchecked Sendable { try await assert( pinsChangedEvent, - wrappedEvent: .sfuEvent(.pinsUpdated(pinsChangedEvent)), + payload: .pinsUpdated(pinsChangedEvent), initialState: participantsAfterJoin ) { guard let pin = $0[participant.sessionId]?.pin else { @@ -256,7 +255,7 @@ final class SFUEventAdapter_Tests: XCTestCase, @unchecked Sendable { try await assert( event, - wrappedEvent: .sfuEvent(.participantJoined(event)), + payload: .participantJoined(event), initialState: [.dummy()].reduce(into: [String: CallParticipant]()) { $0[$1.sessionId] = $1 } ) { $0[participant.sessionId] == nil @@ -273,7 +272,7 @@ final class SFUEventAdapter_Tests: XCTestCase, @unchecked Sendable { try await assert( event, - wrappedEvent: .sfuEvent(.participantJoined(event)), + payload: .participantJoined(event), initialState: [ .dummy(), .dummy(), @@ -365,7 +364,7 @@ final class SFUEventAdapter_Tests: XCTestCase, @unchecked Sendable { try await assert( event, - wrappedEvent: .sfuEvent(.participantLeft(event)), + payload: .participantLeft(event), initialState: [participant].reduce(into: [String: CallParticipant]()) { $0[$1.sessionId] = $1 } ) { [trackLookupPrefix] participants in XCTAssertTrue(participants.isEmpty) @@ -404,7 +403,7 @@ final class SFUEventAdapter_Tests: XCTestCase, @unchecked Sendable { try await assert( event, - wrappedEvent: .sfuEvent(.participantLeft(event)), + payload: .participantLeft(event), initialState: [participant].reduce(into: [String: CallParticipant]()) { $0[$1.sessionId] = $1 } ) { participants in participants.isEmpty == false @@ -423,7 +422,7 @@ final class SFUEventAdapter_Tests: XCTestCase, @unchecked Sendable { try await assert( event, - wrappedEvent: .sfuEvent(.dominantSpeakerChanged(event)), + payload: .dominantSpeakerChanged(event), initialState: [participantA, participantB].reduce(into: [String: CallParticipant]()) { $0[$1.sessionId] = $1 } ) { $0[participantA.sessionId]?.isDominantSpeaker == true @@ -441,7 +440,7 @@ final class SFUEventAdapter_Tests: XCTestCase, @unchecked Sendable { try await assert( event, - wrappedEvent: .sfuEvent(.healthCheckResponse(event)), + payload: .healthCheckResponse(event), initialState: [:] ) { _ in let participantsCount = await self.stateAdapter.participantsCount @@ -460,7 +459,7 @@ final class SFUEventAdapter_Tests: XCTestCase, @unchecked Sendable { try await assert( event, - wrappedEvent: .sfuEvent(.trackPublished(event)), + payload: .trackPublished(event), initialState: [participant].reduce(into: [String: CallParticipant]()) { $0[$1.sessionId] = $1 } ) { $0.count == 1 && $0[participant.sessionId]?.hasAudio == true @@ -475,7 +474,7 @@ final class SFUEventAdapter_Tests: XCTestCase, @unchecked Sendable { try await assert( event, - wrappedEvent: .sfuEvent(.trackPublished(event)), + payload: .trackPublished(event), initialState: [participant].reduce(into: [String: CallParticipant]()) { $0[$1.sessionId] = $1 } ) { $0.count == 1 && $0[participant.sessionId]?.hasVideo == true @@ -490,7 +489,7 @@ final class SFUEventAdapter_Tests: XCTestCase, @unchecked Sendable { try await assert( event, - wrappedEvent: .sfuEvent(.trackPublished(event)), + payload: .trackPublished(event), initialState: [participant].reduce(into: [String: CallParticipant]()) { $0[$1.sessionId] = $1 } ) { $0.count == 1 && $0[participant.sessionId]?.isScreensharing == true @@ -506,7 +505,7 @@ final class SFUEventAdapter_Tests: XCTestCase, @unchecked Sendable { try await assert( event, - wrappedEvent: .sfuEvent(.trackPublished(event)), + payload: .trackPublished(event), initialState: [:] ) { $0.count == 1 && $0[participant.sessionId]?.hasAudio == true @@ -523,7 +522,7 @@ final class SFUEventAdapter_Tests: XCTestCase, @unchecked Sendable { try await assert( event, - wrappedEvent: .sfuEvent(.trackUnpublished(event)), + payload: .trackUnpublished(event), initialState: [participant].reduce(into: [String: CallParticipant]()) { $0[$1.sessionId] = $1 } ) { $0.count == 1 && $0[participant.sessionId]?.hasAudio == false @@ -539,7 +538,7 @@ final class SFUEventAdapter_Tests: XCTestCase, @unchecked Sendable { try await assert( event, - wrappedEvent: .sfuEvent(.trackUnpublished(event)), + payload: .trackUnpublished(event), initialState: [participant].reduce(into: [String: CallParticipant]()) { $0[$1.sessionId] = $1 } ) { $0.count == 1 @@ -556,7 +555,7 @@ final class SFUEventAdapter_Tests: XCTestCase, @unchecked Sendable { try await assert( event, - wrappedEvent: .sfuEvent(.trackUnpublished(event)), + payload: .trackUnpublished(event), initialState: [participant].reduce(into: [String: CallParticipant]()) { $0[$1.sessionId] = $1 } ) { $0.count == 1 && $0[participant.sessionId]?.hasVideo == false @@ -572,7 +571,7 @@ final class SFUEventAdapter_Tests: XCTestCase, @unchecked Sendable { try await assert( event, - wrappedEvent: .sfuEvent(.trackUnpublished(event)), + payload: .trackUnpublished(event), initialState: [participant].reduce(into: [String: CallParticipant]()) { $0[$1.sessionId] = $1 } ) { $0.count == 1 @@ -589,7 +588,7 @@ final class SFUEventAdapter_Tests: XCTestCase, @unchecked Sendable { try await assert( event, - wrappedEvent: .sfuEvent(.trackUnpublished(event)), + payload: .trackUnpublished(event), initialState: [participant].reduce(into: [String: CallParticipant]()) { $0[$1.sessionId] = $1 } ) { $0.count == 1 && $0[participant.sessionId]?.isScreensharing == false @@ -605,7 +604,7 @@ final class SFUEventAdapter_Tests: XCTestCase, @unchecked Sendable { try await assert( event, - wrappedEvent: .sfuEvent(.trackUnpublished(event)), + payload: .trackUnpublished(event), initialState: [participant].reduce(into: [String: CallParticipant]()) { $0[$1.sessionId] = $1 } ) { $0.count == 1 @@ -623,7 +622,7 @@ final class SFUEventAdapter_Tests: XCTestCase, @unchecked Sendable { try await assert( event, - wrappedEvent: .sfuEvent(.trackUnpublished(event)), + payload: .trackUnpublished(event), initialState: [:] ) { $0.count == 1 && $0[participant.sessionId]?.hasAudio == false @@ -643,7 +642,7 @@ final class SFUEventAdapter_Tests: XCTestCase, @unchecked Sendable { try await assert( event, - wrappedEvent: .sfuEvent(.pinsUpdated(event)), + payload: .pinsUpdated(event), initialState: [participantA, participantB].reduce(into: [String: CallParticipant]()) { $0[$1.sessionId] = $1 } ) { $0[participantA.sessionId]?.pin != nil && $0[participantB.sessionId]?.pin == nil @@ -661,7 +660,7 @@ final class SFUEventAdapter_Tests: XCTestCase, @unchecked Sendable { try await assert( event, - wrappedEvent: .sfuEvent(.pinsUpdated(event)), + payload: .pinsUpdated(event), initialState: [participantA, participantB].reduce(into: [String: CallParticipant]()) { $0[$1.sessionId] = $1 } ) { $0[participantA.sessionId]?.pin?.isLocal == false && $0[participantB.sessionId]?.pin == nil @@ -679,7 +678,7 @@ final class SFUEventAdapter_Tests: XCTestCase, @unchecked Sendable { try await assert( event, - wrappedEvent: .sfuEvent(.pinsUpdated(event)), + payload: .pinsUpdated(event), initialState: [participantA, participantB].reduce(into: [String: CallParticipant]()) { $0[$1.sessionId] = $1 } ) { $0[participantA.sessionId]?.pin != nil && $0[participantB.sessionId]?.pin == nil @@ -703,7 +702,7 @@ final class SFUEventAdapter_Tests: XCTestCase, @unchecked Sendable { try await assert( event, - wrappedEvent: .sfuEvent(.participantUpdated(event)), + payload: .participantUpdated(event), initialState: [participant].reduce(into: [String: CallParticipant]()) { $0[$1.sessionId] = $1 } ) { $0.count == 1 && $0[expectedParticipant.sessionId] == expectedParticipant @@ -720,7 +719,7 @@ final class SFUEventAdapter_Tests: XCTestCase, @unchecked Sendable { try await assert( event, - wrappedEvent: .sfuEvent(.participantUpdated(event)), + payload: .participantUpdated(event), initialState: [:] ) { $0.count == 1 && $0[expectedParticipant.sessionId] == expectedParticipant @@ -745,7 +744,7 @@ final class SFUEventAdapter_Tests: XCTestCase, @unchecked Sendable { try await assert( event, - wrappedEvent: .sfuEvent(.changePublishOptions(event)), + payload: .changePublishOptions(event), initialState: [participantA, participantB].reduce(into: [String: CallParticipant]()) { $0[$1.sessionId] = $1 } ) { _ in await self.stateAdapter.publishOptions == expected } } @@ -765,7 +764,7 @@ final class SFUEventAdapter_Tests: XCTestCase, @unchecked Sendable { ] try await assert( event, - wrappedEvent: .sfuEvent(.inboundStateNotification(event)), + payload: .inboundStateNotification(event), initialState: [participantA, participantB].reduce(into: [String: CallParticipant]()) { $0[$1.sessionId] = $1 } ) { $0[participantA.sessionId]?.pausedTracks == [.video] && $0[participantB.sessionId]?.pausedTracks.isEmpty == true @@ -794,7 +793,7 @@ final class SFUEventAdapter_Tests: XCTestCase, @unchecked Sendable { ] try await assert( event, - wrappedEvent: .sfuEvent(.inboundStateNotification(event)), + payload: .inboundStateNotification(event), initialState: [participantA, participantB, participantC] .reduce(into: [String: CallParticipant]()) { $0[$1.sessionId] = $1 } ) { @@ -816,7 +815,7 @@ final class SFUEventAdapter_Tests: XCTestCase, @unchecked Sendable { ] try await assert( event, - wrappedEvent: .sfuEvent(.inboundStateNotification(event)), + payload: .inboundStateNotification(event), initialState: [participantA, participantB].reduce(into: [String: CallParticipant]()) { $0[$1.sessionId] = $1 } ) { $0[participantA.sessionId]?.pausedTracks.isEmpty == true @@ -846,7 +845,7 @@ final class SFUEventAdapter_Tests: XCTestCase, @unchecked Sendable { ] try await assert( event, - wrappedEvent: .sfuEvent(.inboundStateNotification(event)), + payload: .inboundStateNotification(event), initialState: [participantA, participantB, participantC] .reduce(into: [String: CallParticipant]()) { $0[$1.sessionId] = $1 } ) { @@ -879,7 +878,7 @@ final class SFUEventAdapter_Tests: XCTestCase, @unchecked Sendable { ] try await assert( event, - wrappedEvent: .sfuEvent(.inboundStateNotification(event)), + payload: .inboundStateNotification(event), initialState: [participantA, participantB, participantC] .reduce(into: [String: CallParticipant]()) { $0[$1.sessionId] = $1 } ) { @@ -893,7 +892,7 @@ final class SFUEventAdapter_Tests: XCTestCase, @unchecked Sendable { private func assert( _ event: T, - wrappedEvent: WrappedEvent, + payload: Stream_Video_Sfu_Event_SfuEvent.OneOf_EventPayload, initialState: [String: CallParticipant], handler: @Sendable @escaping ([String: CallParticipant]) async throws -> Bool ) async throws { @@ -907,7 +906,7 @@ final class SFUEventAdapter_Tests: XCTestCase, @unchecked Sendable { /// the stateAdapter spins up another task to complete the update. await withTaskGroup(of: Void.self) { group in group.addTask { - self.mockWebSocket.eventSubject.send(wrappedEvent) + self.mockWebSocket.receive(payload) await self.fulfillment(of: [eventExpectation], timeout: defaultTimeout) } group.addTask { diff --git a/StreamVideoTests/WebRTC/SFU/SFUWebSocket_Tests.swift b/StreamVideoTests/WebRTC/SFU/SFUWebSocket_Tests.swift new file mode 100644 index 000000000..8b6dee9d2 --- /dev/null +++ b/StreamVideoTests/WebRTC/SFU/SFUWebSocket_Tests.swift @@ -0,0 +1,83 @@ +// +// Copyright © 2026 Stream.io Inc. All rights reserved. +// + +import Combine +import Foundation +import StreamCore +@testable import StreamVideo +import XCTest + +final class SFUWebSocket_Tests: XCTestCase, @unchecked Sendable { + + func test_closeCodeProvider_noPongReceived_returns4001() { + let subject = SFUWebSocketCloseCodeProvider() + + let closeCode = subject.closeCode( + for: .disconnection(source: .noPongReceived) + ) + + XCTAssertEqual(closeCode.rawValue, 4001) + } + + func test_closeCodeProvider_reconfiguration_returns4002() { + let subject = SFUWebSocketCloseCodeProvider() + + let closeCode = subject.closeCode(for: .reconfiguration) + + XCTAssertEqual(closeCode.rawValue, 4002) + } + + func test_closeCodeProvider_disconnection_returnsNormalClosure() { + let subject = SFUWebSocketCloseCodeProvider() + + let closeCode = subject.closeCode( + for: .disconnection(source: .systemInitiated) + ) + + XCTAssertEqual(closeCode, .normalClosure) + } + + func test_closeCodeProvider_explicitCode_returnsRequestedCode() { + let subject = SFUWebSocketCloseCodeProvider() + + let closeCode = subject.closeCode( + for: .explicit(code: .goingAway, source: .userInitiated) + ) + + XCTAssertEqual(closeCode, .goingAway) + } + + func test_makeSFUHealthCheckPing_wrapsHealthCheckInSFURequest() throws { + let event = try XCTUnwrap( + makeSFUHealthCheckPing() as? Stream_Video_Sfu_Event_SfuRequest + ) + + XCTAssertEqual( + event.requestPayload, + .healthCheckRequest(.init()) + ) + } + + func test_inject_typedPayload_publishesSamePayload() { + let subject = SFUWebSocket( + url: URL(string: "https://getstream.io")!, + sessionConfiguration: .ephemeral + ) + let expected = + Stream_Video_Sfu_Event_SfuEvent.OneOf_EventPayload + .healthCheckResponse(.init()) + let expectation = expectation(description: "Typed SFU event") + var received: Stream_Video_Sfu_Event_SfuEvent.OneOf_EventPayload? + let cancellable = subject.eventPublisher.sink { + received = $0 + expectation.fulfill() + } + + subject.inject(expected) + + wait(for: [expectation], timeout: defaultTimeout) + XCTAssertEqual(received, expected) + cancellable.cancel() + } +} diff --git a/StreamVideoTests/WebRTC/SFU/WebRTCEventDecoder_Tests.swift b/StreamVideoTests/WebRTC/SFU/WebRTCEventDecoder_Tests.swift new file mode 100644 index 000000000..7987541f7 --- /dev/null +++ b/StreamVideoTests/WebRTC/SFU/WebRTCEventDecoder_Tests.swift @@ -0,0 +1,80 @@ +// +// Copyright © 2026 Stream.io Inc. All rights reserved. +// + +import StreamCore +@testable import StreamVideo +import XCTest + +final class WebRTCEventDecoder_Tests: XCTestCase, @unchecked Sendable { + + private var subject: WebRTCEventDecoder! + + override func setUp() { + super.setUp() + subject = WebRTCEventDecoder() + } + + override func tearDown() { + subject = nil + super.tearDown() + } + + func test_decode_validProtobuf_returnsTypedPayload() throws { + var participantCount = Stream_Video_Sfu_Models_ParticipantCount() + participantCount.total = 2 + var healthCheck = Stream_Video_Sfu_Event_HealthCheckResponse() + healthCheck.participantCount = participantCount + var event = Stream_Video_Sfu_Event_SfuEvent() + event.healthCheckResponse = healthCheck + + let result = try subject.decode( + from: event.serializedData(partial: false) + ) + + XCTAssertEqual( + result as? Stream_Video_Sfu_Event_SfuEvent.OneOf_EventPayload, + .healthCheckResponse(healthCheck) + ) + XCTAssertEqual(result.healthcheck()?.participantCount, 2) + } + + func test_decode_emptyProtobuf_throwsIgnoredEventType() throws { + let event = Stream_Video_Sfu_Event_SfuEvent() + + XCTAssertThrowsError( + try subject.decode(from: event.serializedData(partial: false)) + ) { + XCTAssertTrue($0 is ClientError.IgnoredEventType) + } + } + + func test_decode_malformedProtobuf_throws() { + XCTAssertThrowsError( + try subject.decode(from: Data([0x0a])) + ) + } + + func test_decode_errorProtobuf_returnsTypedTerminalPayload() throws { + var error = Stream_Video_Sfu_Models_Error() + error.message = .unique + var errorEvent = Stream_Video_Sfu_Event_Error() + errorEvent.error = error + errorEvent.reconnectStrategy = .migrate + var event = Stream_Video_Sfu_Event_SfuEvent() + event.error = errorEvent + + let result = try subject.decode( + from: event.serializedData(partial: false) + ) + + XCTAssertEqual( + result as? Stream_Video_Sfu_Event_SfuEvent.OneOf_EventPayload, + .error(errorEvent) + ) + XCTAssertEqual( + (result.error() as? Stream_Video_Sfu_Models_Error)?.message, + error.message + ) + } +} diff --git a/StreamVideoTests/WebRTC/v2/PeerConnection/Adapters/ICEAdapter_Tests.swift b/StreamVideoTests/WebRTC/v2/PeerConnection/Adapters/ICEAdapter_Tests.swift index 1cf7e5ba4..86cbd2b48 100644 --- a/StreamVideoTests/WebRTC/v2/PeerConnection/Adapters/ICEAdapter_Tests.swift +++ b/StreamVideoTests/WebRTC/v2/PeerConnection/Adapters/ICEAdapter_Tests.swift @@ -3,6 +3,7 @@ // import Combine +import StreamCore @testable import StreamVideo import StreamWebRTC @preconcurrency import XCTest @@ -117,7 +118,7 @@ final class ICEAdapterTests: XCTestCase, @unchecked Sendable { await wait(for: 0.5) var candidate = Stream_Video_Sfu_Models_ICETrickle() candidate.iceCandidate = iceCandidate.sdp - mockSFUStack.receiveEvent(.sfuEvent(.iceTrickle(candidate))) + mockSFUStack.receiveEvent(.iceTrickle(candidate)) await wait(for: 1.0) // Wait until the event has been processed from the ICEAdapter mockPeerConnection @@ -163,7 +164,7 @@ final class ICEAdapterTests: XCTestCase, @unchecked Sendable { } """ event.sessionID = .unique - mockSFUStack.receiveEvent(.sfuEvent(.iceTrickle(event))) + mockSFUStack.receiveEvent(.iceTrickle(event)) await fulfillment { self.mockPeerConnection.timesCalled(.addCandidate) == 1 } let candidate = try XCTUnwrap( @@ -197,7 +198,7 @@ final class ICEAdapterTests: XCTestCase, @unchecked Sendable { } """ - mockSFUStack.receiveEvent(.sfuEvent(.iceTrickle(event))) + mockSFUStack.receiveEvent(.iceTrickle(event)) await wait(for: 0.5) XCTAssertEqual(mockPeerConnection.timesCalled(.addCandidate), 0) @@ -241,7 +242,7 @@ final class ICEAdapterTests: XCTestCase, @unchecked Sendable { } """ - mockSFUStack.receiveEvent(.sfuEvent(.iceTrickle(event))) + mockSFUStack.receiveEvent(.iceTrickle(event)) await wait(for: 0.5) XCTAssertEqual(mockPeerConnection.timesCalled(.addCandidate), 0) diff --git a/StreamVideoTests/WebRTC/v2/PeerConnection/RTCPeerConnectionCoordinator_Tests.swift b/StreamVideoTests/WebRTC/v2/PeerConnection/RTCPeerConnectionCoordinator_Tests.swift index 4b9579fa7..7cd4f2bb9 100644 --- a/StreamVideoTests/WebRTC/v2/PeerConnection/RTCPeerConnectionCoordinator_Tests.swift +++ b/StreamVideoTests/WebRTC/v2/PeerConnection/RTCPeerConnectionCoordinator_Tests.swift @@ -621,7 +621,7 @@ final class RTCPeerConnectionCoordinator_Tests: XCTestCase, @unchecked Sendable func test_handleSubscriberOffer_subjectIsPublisher_doesNotCallSetRemoteDescription() async throws { _ = subject - mockSFUStack.receiveEvent(.sfuEvent(.subscriberOffer(Stream_Video_Sfu_Event_SubscriberOffer()))) + mockSFUStack.receiveEvent(.subscriberOffer(Stream_Video_Sfu_Event_SubscriberOffer())) await wait(for: 1) XCTAssertEqual(mockPeerConnection?.timesCalled(.setRemoteDescription), 0) @@ -635,7 +635,7 @@ final class RTCPeerConnectionCoordinator_Tests: XCTestCase, @unchecked Sendable var offer = Stream_Video_Sfu_Event_SubscriberOffer() offer.sdp = .unique - mockSFUStack.receiveEvent(.sfuEvent(.subscriberOffer(offer))) + mockSFUStack.receiveEvent(.subscriberOffer(offer)) await fulfillment { [mockPeerConnection] in mockPeerConnection?.timesCalled(.setRemoteDescription) == 1 @@ -656,7 +656,7 @@ final class RTCPeerConnectionCoordinator_Tests: XCTestCase, @unchecked Sendable var offer = Stream_Video_Sfu_Event_SubscriberOffer() offer.sdp = .unique - mockSFUStack.receiveEvent(.sfuEvent(.subscriberOffer(offer))) + mockSFUStack.receiveEvent(.subscriberOffer(offer)) await fulfillment { [mockPeerConnection] in mockPeerConnection?.timesCalled(.answer) == 1 @@ -671,7 +671,7 @@ final class RTCPeerConnectionCoordinator_Tests: XCTestCase, @unchecked Sendable var offer = Stream_Video_Sfu_Event_SubscriberOffer() offer.sdp = .unique - mockSFUStack.receiveEvent(.sfuEvent(.subscriberOffer(offer))) + mockSFUStack.receiveEvent(.subscriberOffer(offer)) await fulfillment { [mockPeerConnection] in mockPeerConnection?.timesCalled(.setLocalDescription) == 1 @@ -696,7 +696,7 @@ final class RTCPeerConnectionCoordinator_Tests: XCTestCase, @unchecked Sendable with: RTCSessionDescription(type: .offer, sdp: sdp) ) - mockSFUStack.receiveEvent(.sfuEvent(.subscriberOffer(Stream_Video_Sfu_Event_SubscriberOffer()))) + mockSFUStack.receiveEvent(.subscriberOffer(Stream_Video_Sfu_Event_SubscriberOffer())) await fulfillment { [mockSFUStack] in mockSFUStack?.service.sendAnswerWasCalledWithRequest != nil @@ -802,7 +802,7 @@ final class RTCPeerConnectionCoordinator_Tests: XCTestCase, @unchecked Sendable var payload = Stream_Video_Sfu_Event_ICERestart() payload.peerType = .publisherUnspecified - mockSFUStack.receiveEvent(.sfuEvent(.iceRestart(payload))) + mockSFUStack.receiveEvent(.iceRestart(payload)) await fulfillment { [mockPeerConnection] in mockPeerConnection?.timesCalled(.offer) == 1 @@ -822,7 +822,7 @@ final class RTCPeerConnectionCoordinator_Tests: XCTestCase, @unchecked Sendable var payload = Stream_Video_Sfu_Event_ICERestart() payload.peerType = .publisherUnspecified - mockSFUStack.receiveEvent(.sfuEvent(.iceRestart(payload))) + mockSFUStack.receiveEvent(.iceRestart(payload)) await wait(for: 1) XCTAssertEqual(mockPeerConnection?.timesCalled(.offer), 0) @@ -835,7 +835,7 @@ final class RTCPeerConnectionCoordinator_Tests: XCTestCase, @unchecked Sendable var payload = Stream_Video_Sfu_Event_ICERestart() payload.peerType = .subscriber - mockSFUStack.receiveEvent(.sfuEvent(.iceRestart(payload))) + mockSFUStack.receiveEvent(.iceRestart(payload)) await wait(for: 1) XCTAssertEqual(mockPeerConnection?.timesCalled(.offer), 0) @@ -849,7 +849,7 @@ final class RTCPeerConnectionCoordinator_Tests: XCTestCase, @unchecked Sendable var payload = Stream_Video_Sfu_Event_ICERestart() payload.peerType = .subscriber - mockSFUStack.receiveEvent(.sfuEvent(.iceRestart(payload))) + mockSFUStack.receiveEvent(.iceRestart(payload)) await fulfillment { [mockSFUStack] in mockSFUStack?.service.iceRestartWasCalledWithRequest?.sessionID == self.sessionId @@ -863,7 +863,7 @@ final class RTCPeerConnectionCoordinator_Tests: XCTestCase, @unchecked Sendable var payload = Stream_Video_Sfu_Event_ICERestart() payload.peerType = .publisherUnspecified - mockSFUStack.receiveEvent(.sfuEvent(.iceRestart(payload))) + mockSFUStack.receiveEvent(.iceRestart(payload)) await wait(for: 1) XCTAssertNil(mockSFUStack?.service.iceRestartWasCalledWithRequest) diff --git a/StreamVideoTests/WebRTC/v2/StateMachine/Stages/WebRTCCoordinatorStateMachine_FastReconnectingStageTests.swift b/StreamVideoTests/WebRTC/v2/StateMachine/Stages/WebRTCCoordinatorStateMachine_FastReconnectingStageTests.swift index 8d4eb8f1e..b84f19728 100644 --- a/StreamVideoTests/WebRTC/v2/StateMachine/Stages/WebRTCCoordinatorStateMachine_FastReconnectingStageTests.swift +++ b/StreamVideoTests/WebRTC/v2/StateMachine/Stages/WebRTCCoordinatorStateMachine_FastReconnectingStageTests.swift @@ -104,66 +104,32 @@ final class WebRTCCoordinatorStateMachine_FastReconnectingStageTests: XCTestCase _ = subject.transition(from: .disconnected(subject.context)) await wait(for: 0.5) - let callType = ( - code: URLSessionWebSocketTask.CloseCode, - source: WebSocketConnectionState.DisconnectionSource, - completion: () -> Void - ).self - XCTAssertNil(webSocket.connectionStateDelegate) - XCTAssertEqual(webSocket.timesCalled(.disconnect), 1) - XCTAssertEqual( - webSocket.recordedInputPayload( - callType, - for: .disconnect - )?.first?.code, - .init(rawValue: 4002) - ) + XCTAssertEqual(webSocket.timesCalled(.disconnectForReconfiguration), 1) + XCTAssertEqual(webSocket.timesCalled(.disconnect), 0) } func test_transition_refreshWasCalledOnSFUAdapter_newWebSocketClientWasConfiguredCorrectly() async throws { subject.context.coordinator = mockCoordinatorStack.coordinator let sfuAdapter = mockCoordinatorStack.sfuStack.adapter await mockCoordinatorStack.coordinator.stateAdapter.set(sfuAdapter: sfuAdapter) - let newWebSocket = MockWebSocketClient(webSocketClientType: .sfu) - mockCoordinatorStack.sfuStack.webSocketFactory.stub( - for: .build, - with: newWebSocket - ) + let expectedURL = sfuAdapter.connectURL + let newWebSocket = MockSFUWebSocket() + mockCoordinatorStack.sfuStack.nextWebSocket = newWebSocket _ = subject.transition(from: .disconnected(subject.context)) await wait(for: 0.5) - let buildCallType = ( - sessionConfiguration: URLSessionConfiguration, - eventDecoder: AnyEventDecoder, - eventNotificationCenter: EventNotificationCenter, - webSocketClientType: WebSocketClientType, - environment: WebSocketClient.Environment, - connectURL: URL, - requiresAuth: Bool - ).self - let newWebSocketBuildInput = try XCTUnwrap( - mockCoordinatorStack - .sfuStack - .webSocketFactory - .recordedInputPayload(buildCallType, for: .build)? - .first - ) - XCTAssertEqual(newWebSocketBuildInput.webSocketClientType, .sfu) - XCTAssertEqual(newWebSocketBuildInput.connectURL, sfuAdapter.connectURL) - XCTAssertFalse(newWebSocketBuildInput.requiresAuth) - XCTAssertTrue(newWebSocket.connectionStateDelegate === sfuAdapter) + // After refresh, the adapter operates on the newly built socket. + XCTAssertEqual(sfuAdapter.connectURL, expectedURL) + XCTAssertEqual(newWebSocket.timesCalled(.connect), 1) } func test_transition_connectWasCalledOnSFUAdapter() async throws { subject.context.coordinator = mockCoordinatorStack.coordinator let sfuAdapter = mockCoordinatorStack.sfuStack.adapter await mockCoordinatorStack.coordinator.stateAdapter.set(sfuAdapter: sfuAdapter) - let newWebSocket = MockWebSocketClient(webSocketClientType: .sfu) - mockCoordinatorStack.sfuStack.webSocketFactory.stub( - for: .build, - with: newWebSocket - ) + let newWebSocket = MockSFUWebSocket() + mockCoordinatorStack.sfuStack.nextWebSocket = newWebSocket _ = subject.transition(from: .disconnected(subject.context)) await wait(for: 0.5) @@ -176,11 +142,8 @@ final class WebRTCCoordinatorStateMachine_FastReconnectingStageTests: XCTestCase subject.context.reconnectionStrategy = .fast(disconnectedSince: .init(), deadline: 12) let sfuAdapter = mockCoordinatorStack.sfuStack.adapter await mockCoordinatorStack.coordinator.stateAdapter.set(sfuAdapter: sfuAdapter) - let newWebSocket = MockWebSocketClient(webSocketClientType: .sfu) - mockCoordinatorStack.sfuStack.webSocketFactory.stub( - for: .build, - with: newWebSocket - ) + let newWebSocket = MockSFUWebSocket() + mockCoordinatorStack.sfuStack.nextWebSocket = newWebSocket await assertTransitionAfterTrigger( expectedTarget: .fastReconnected @@ -194,11 +157,8 @@ final class WebRTCCoordinatorStateMachine_FastReconnectingStageTests: XCTestCase subject.context.reconnectionStrategy = .fast(disconnectedSince: .init(), deadline: 12) let sfuAdapter = mockCoordinatorStack.sfuStack.adapter await mockCoordinatorStack.coordinator.stateAdapter.set(sfuAdapter: sfuAdapter) - let newWebSocket = MockWebSocketClient(webSocketClientType: .sfu) - mockCoordinatorStack.sfuStack.webSocketFactory.stub( - for: .build, - with: newWebSocket - ) + let newWebSocket = MockSFUWebSocket() + mockCoordinatorStack.sfuStack.nextWebSocket = newWebSocket await assertTransitionAfterTrigger( expectedTarget: .disconnected diff --git a/StreamVideoTests/WebRTC/v2/StateMachine/Stages/WebRTCCoordinatorStateMachine_JoinedStageTests.swift b/StreamVideoTests/WebRTC/v2/StateMachine/Stages/WebRTCCoordinatorStateMachine_JoinedStageTests.swift index 470848609..f7b845518 100644 --- a/StreamVideoTests/WebRTC/v2/StateMachine/Stages/WebRTCCoordinatorStateMachine_JoinedStageTests.swift +++ b/StreamVideoTests/WebRTC/v2/StateMachine/Stages/WebRTCCoordinatorStateMachine_JoinedStageTests.swift @@ -160,10 +160,8 @@ final class WebRTCCoordinatorStateMachine_JoinedStageTests: XCTestCase, @uncheck ) subject.context.migrationStatusObserver = migrationStatusObserver let cancellable = receiveEvent( - .sfuEvent( - .participantMigrationComplete( - Stream_Video_Sfu_Event_ParticipantMigrationComplete() - ) + .participantMigrationComplete( + Stream_Video_Sfu_Event_ParticipantMigrationComplete() ), every: 0.1 ) @@ -196,14 +194,10 @@ final class WebRTCCoordinatorStateMachine_JoinedStageTests: XCTestCase, @uncheck default: XCTFail() } - XCTAssertEqual( - target.context.disconnectionSource, - .serverInitiated( - error: .NetworkError( - "Not available" - ) - ) - ) + guard case let .serverInitiated(error)? = target.context.disconnectionSource else { + return XCTFail() + } + XCTAssertEqual(error?.localizedDescription, "Not available") } } @@ -343,16 +337,21 @@ final class WebRTCCoordinatorStateMachine_JoinedStageTests: XCTestCase, @uncheck sfuAdapter: mockCoordinatorStack.sfuStack.adapter ) subject.context.fastReconnectDeadlineSeconds = 12 - var sfuError = Stream_Video_Sfu_Models_Error() - sfuError.shouldRetry = true - let clientError = ClientError(with: sfuError) + let sfuError = { + var result = Stream_Video_Sfu_Models_Error() + result.shouldRetry = true + return result + }() + let expectedSource: WebSocketConnectionState.DisconnectionSource = .serverInitiated( + error: .init(with: sfuError) + ) await assertTransitionAfterTrigger( expectedTarget: .disconnected, trigger: { [mockCoordinatorStack] in mockCoordinatorStack?.sfuStack.setConnectionState( to: .disconnected( - source: .serverInitiated(error: clientError) + source: .serverInitiated(error: .init(with: sfuError)) ) ) } @@ -365,7 +364,7 @@ final class WebRTCCoordinatorStateMachine_JoinedStageTests: XCTestCase, @uncheck } XCTAssertEqual( target.context.disconnectionSource, - .serverInitiated(error: clientError) + expectedSource ) } } @@ -375,16 +374,21 @@ final class WebRTCCoordinatorStateMachine_JoinedStageTests: XCTestCase, @uncheck sfuAdapter: mockCoordinatorStack.sfuStack.adapter ) subject.context.fastReconnectDeadlineSeconds = 12 - var sfuError = Stream_Video_Sfu_Models_Error() - sfuError.shouldRetry = false - let clientError = ClientError(with: sfuError) + let sfuError = { + var result = Stream_Video_Sfu_Models_Error() + result.shouldRetry = false + return result + }() + let expectedSource: WebSocketConnectionState.DisconnectionSource = .serverInitiated( + error: .init(with: sfuError) + ) await assertTransitionAfterTrigger( expectedTarget: .disconnected, trigger: { [mockCoordinatorStack] in mockCoordinatorStack?.sfuStack.setConnectionState( to: .disconnected( - source: .serverInitiated(error: clientError) + source: .serverInitiated(error: .init(with: sfuError)) ) ) } @@ -392,7 +396,7 @@ final class WebRTCCoordinatorStateMachine_JoinedStageTests: XCTestCase, @uncheck XCTAssertEqual(target.context.reconnectionStrategy, .rejoin) XCTAssertEqual( target.context.disconnectionSource, - .serverInitiated(error: clientError) + expectedSource ) } } @@ -409,7 +413,7 @@ final class WebRTCCoordinatorStateMachine_JoinedStageTests: XCTestCase, @uncheck trigger: { [mockCoordinatorStack] in mockCoordinatorStack? .sfuStack - .receiveEvent(.sfuEvent(.callEnded(Stream_Video_Sfu_Event_CallEnded()))) + .receiveEvent(.callEnded(Stream_Video_Sfu_Event_CallEnded())) } ) { _ in } } @@ -428,7 +432,7 @@ final class WebRTCCoordinatorStateMachine_JoinedStageTests: XCTestCase, @uncheck error.reconnectStrategy = .migrate mockCoordinatorStack? .sfuStack - .receiveEvent(.sfuEvent(.error(error))) + .receiveEvent(.error(error)) } ) { target in XCTAssertEqual(target.context.reconnectionStrategy, .migrate) @@ -446,7 +450,7 @@ final class WebRTCCoordinatorStateMachine_JoinedStageTests: XCTestCase, @uncheck error.reconnectStrategy = .rejoin mockCoordinatorStack? .sfuStack - .receiveEvent(.sfuEvent(.error(error))) + .receiveEvent(.error(error)) } ) { target in XCTAssertEqual(target.context.reconnectionStrategy, .rejoin) @@ -465,7 +469,7 @@ final class WebRTCCoordinatorStateMachine_JoinedStageTests: XCTestCase, @uncheck error.reconnectStrategy = .fast mockCoordinatorStack? .sfuStack - .receiveEvent(.sfuEvent(.error(error))) + .receiveEvent(.error(error)) } ) { target in switch target.context.reconnectionStrategy { @@ -487,7 +491,7 @@ final class WebRTCCoordinatorStateMachine_JoinedStageTests: XCTestCase, @uncheck trigger: { [mockCoordinatorStack] in mockCoordinatorStack? .sfuStack - .receiveEvent(.sfuEvent(.goAway(Stream_Video_Sfu_Event_GoAway()))) + .receiveEvent(.goAway(Stream_Video_Sfu_Event_GoAway())) } ) { target in XCTAssertEqual(target.context.reconnectionStrategy, .migrate) @@ -508,7 +512,7 @@ final class WebRTCCoordinatorStateMachine_JoinedStageTests: XCTestCase, @uncheck error.reconnectStrategy = .disconnect mockCoordinatorStack? .sfuStack - .receiveEvent(.sfuEvent(.error(error))) + .receiveEvent(.error(error)) } ) { _ in } } @@ -529,7 +533,7 @@ final class WebRTCCoordinatorStateMachine_JoinedStageTests: XCTestCase, @uncheck error.error.code = .participantSignalLost mockCoordinatorStack? .sfuStack - .receiveEvent(.sfuEvent(.error(error))) + .receiveEvent(.error(error)) } ) { stage in switch stage.context.reconnectionStrategy { @@ -556,7 +560,7 @@ final class WebRTCCoordinatorStateMachine_JoinedStageTests: XCTestCase, @uncheck error.reconnectStrategy = .migrate mockCoordinatorStack? .sfuStack - .receiveEvent(.sfuEvent(.error(error))) + .receiveEvent(.error(error)) } ) { stage in XCTAssertEqual(stage.context.reconnectionStrategy, .migrate) @@ -577,7 +581,7 @@ final class WebRTCCoordinatorStateMachine_JoinedStageTests: XCTestCase, @uncheck error.reconnectStrategy = .migrate mockCoordinatorStack? .sfuStack - .receiveEvent(.sfuEvent(.error(error))) + .receiveEvent(.error(error)) } ) { stage in XCTAssertEqual(stage.context.reconnectionStrategy, .migrate) @@ -598,7 +602,7 @@ final class WebRTCCoordinatorStateMachine_JoinedStageTests: XCTestCase, @uncheck error.reconnectStrategy = .migrate mockCoordinatorStack? .sfuStack - .receiveEvent(.sfuEvent(.error(error))) + .receiveEvent(.error(error)) } ) { stage in XCTAssertEqual(stage.context.reconnectionStrategy, .migrate) @@ -619,7 +623,7 @@ final class WebRTCCoordinatorStateMachine_JoinedStageTests: XCTestCase, @uncheck error.reconnectStrategy = .rejoin mockCoordinatorStack? .sfuStack - .receiveEvent(.sfuEvent(.error(error))) + .receiveEvent(.error(error)) } ) { stage in XCTAssertEqual(stage.context.reconnectionStrategy, .rejoin) @@ -659,7 +663,7 @@ final class WebRTCCoordinatorStateMachine_JoinedStageTests: XCTestCase, @uncheck subject.context.webSocketHealthTimeout = 5 await assertResultAfterTrigger { - self.mockCoordinatorStack.sfuStack.receiveEvent(.sfuEvent(.healthCheckResponse(.init()))) + self.mockCoordinatorStack.sfuStack.receiveEvent(.healthCheckResponse(.init())) } validationHandler: { expectation in guard let currentLastHealthCheckReceivedAt = self.subject.context.lastHealthCheckReceivedAt, @@ -738,7 +742,7 @@ final class WebRTCCoordinatorStateMachine_JoinedStageTests: XCTestCase, @uncheck error.reconnectStrategy = .fast mockCoordinatorStack? .sfuStack - .receiveEvent(.sfuEvent(.error(error))) + .receiveEvent(.error(error)) } ) { target in switch target.context.reconnectionStrategy { @@ -762,7 +766,7 @@ final class WebRTCCoordinatorStateMachine_JoinedStageTests: XCTestCase, @uncheck error.reconnectStrategy = .rejoin mockCoordinatorStack? .sfuStack - .receiveEvent(.sfuEvent(.error(error))) + .receiveEvent(.error(error)) } ) { target in XCTAssertEqual(target.context.reconnectionStrategy, .rejoin) @@ -1059,7 +1063,7 @@ final class WebRTCCoordinatorStateMachine_JoinedStageTests: XCTestCase, @uncheck } private func receiveEvent( - _ event: WrappedEvent, + _ event: Stream_Video_Sfu_Event_SfuEvent.OneOf_EventPayload, every timeInterval: TimeInterval ) -> AnyCancellable { Foundation diff --git a/StreamVideoTests/WebRTC/v2/StateMachine/Stages/WebRTCCoordinatorStateMachine_JoiningStageTests.swift b/StreamVideoTests/WebRTC/v2/StateMachine/Stages/WebRTCCoordinatorStateMachine_JoiningStageTests.swift index d30beb31b..9274ce72b 100644 --- a/StreamVideoTests/WebRTC/v2/StateMachine/Stages/WebRTCCoordinatorStateMachine_JoiningStageTests.swift +++ b/StreamVideoTests/WebRTC/v2/StateMachine/Stages/WebRTCCoordinatorStateMachine_JoiningStageTests.swift @@ -83,11 +83,11 @@ final class WebRTCCoordinatorStateMachine_JoiningStageTests: XCTestCase, @unchec _ = subject.transition(from: .fastReconnected(subject.context)) await fulfillment("Join request was not sent before cancellation.") { - let webSocketEngine = self.mockCoordinatorStack.sfuStack.webSocket.mockEngine + let webSocketEngine = self.mockCoordinatorStack.sfuStack.webSocket guard let requests = webSocketEngine.recordedInputPayload( Stream_Video_Sfu_Event_SfuRequest.self, - for: .sendMessage + for: .send ) else { return false @@ -97,7 +97,7 @@ final class WebRTCCoordinatorStateMachine_JoiningStageTests: XCTestCase, @unchec subject.willTransitionAway() mockCoordinatorStack.sfuStack.receiveEvent( - .sfuEvent(.joinResponse(Stream_Video_Sfu_Event_JoinResponse())) + .joinResponse(Stream_Video_Sfu_Event_JoinResponse()) ) await fulfillment(of: [unexpectedTransition], timeout: 0.2) @@ -161,11 +161,11 @@ final class WebRTCCoordinatorStateMachine_JoiningStageTests: XCTestCase, @unchec expectedTarget: .disconnected, subject: subject ) { [mockCoordinatorStack] _ in - let webSocketEngine = try XCTUnwrap(mockCoordinatorStack?.sfuStack.webSocket.mockEngine) + let webSocketEngine = try XCTUnwrap(mockCoordinatorStack?.sfuStack.webSocket) let request = try XCTUnwrap( webSocketEngine.recordedInputPayload( Stream_Video_Sfu_Event_SfuRequest.self, - for: .sendMessage + for: .send )?.first ) let sessionID = await mockCoordinatorStack?.coordinator.stateAdapter.sessionID @@ -255,7 +255,7 @@ final class WebRTCCoordinatorStateMachine_JoiningStageTests: XCTestCase, @unchec .set(sfuAdapter: mockCoordinatorStack.sfuStack.adapter) let cancellable = receiveEvent( - .sfuEvent(.joinResponse(Stream_Video_Sfu_Event_JoinResponse())), + .joinResponse(Stream_Video_Sfu_Event_JoinResponse()), every: 0.3 ) @@ -280,7 +280,7 @@ final class WebRTCCoordinatorStateMachine_JoiningStageTests: XCTestCase, @unchec .set(sfuAdapter: mockCoordinatorStack.sfuStack.adapter) let cancellable = receiveEvent( - .sfuEvent(.joinResponse(Stream_Video_Sfu_Event_JoinResponse())), + .joinResponse(Stream_Video_Sfu_Event_JoinResponse()), every: 0.3 ) @@ -289,11 +289,11 @@ final class WebRTCCoordinatorStateMachine_JoiningStageTests: XCTestCase, @unchec expectedTarget: .disconnected, subject: subject ) { [mockCoordinatorStack] _ in - let webSocketEngine = try XCTUnwrap(mockCoordinatorStack?.sfuStack.webSocket.mockEngine) + let webSocketEngine = try XCTUnwrap(mockCoordinatorStack?.sfuStack.webSocket) let requests = try XCTUnwrap( webSocketEngine.recordedInputPayload( Stream_Video_Sfu_Event_HealthCheckRequest.self, - for: .sendMessage + for: .send ) ) XCTAssertEqual(requests.count, 1) @@ -312,7 +312,7 @@ final class WebRTCCoordinatorStateMachine_JoiningStageTests: XCTestCase, @unchec var response = Stream_Video_Sfu_Event_JoinResponse() response.fastReconnectDeadlineSeconds = 22 let cancellable = receiveEvent( - .sfuEvent(.joinResponse(response)), + .joinResponse(response), every: 0.3 ) @@ -333,7 +333,7 @@ final class WebRTCCoordinatorStateMachine_JoiningStageTests: XCTestCase, @unchec .set(sfuAdapter: mockCoordinatorStack.sfuStack.adapter) mockCoordinatorStack.webRTCAuthenticator.stubbedFunction[.waitForConnect] = Result.success(()) let cancellable = receiveEvent( - .sfuEvent(.joinResponse(Stream_Video_Sfu_Event_JoinResponse())), + .joinResponse(Stream_Video_Sfu_Event_JoinResponse()), every: 0.3 ) @@ -359,7 +359,7 @@ final class WebRTCCoordinatorStateMachine_JoiningStageTests: XCTestCase, @unchec .set(sfuAdapter: mockCoordinatorStack.sfuStack.adapter) mockCoordinatorStack.webRTCAuthenticator.stubbedFunction[.waitForConnect] = Result.success(()) let cancellable = receiveEvent( - .sfuEvent(.joinResponse(Stream_Video_Sfu_Event_JoinResponse())), + .joinResponse(Stream_Video_Sfu_Event_JoinResponse()), every: 0.3 ) @@ -403,7 +403,7 @@ final class WebRTCCoordinatorStateMachine_JoiningStageTests: XCTestCase, @unchec participantBuilder() ] let cancellable = receiveEvent( - .sfuEvent(.joinResponse(response)), + .joinResponse(response), every: 0.3 ) @@ -430,7 +430,7 @@ final class WebRTCCoordinatorStateMachine_JoiningStageTests: XCTestCase, @unchec var response = Stream_Video_Sfu_Event_JoinResponse() response.fastReconnectDeadlineSeconds = 22 let cancellable = receiveEvent( - .sfuEvent(.joinResponse(response)), + .joinResponse(response), every: 0.3 ) @@ -458,7 +458,7 @@ final class WebRTCCoordinatorStateMachine_JoiningStageTests: XCTestCase, @unchec .set(sfuAdapter: mockCoordinatorStack.sfuStack.adapter) mockCoordinatorStack.webRTCAuthenticator.stubbedFunction[.waitForConnect] = Result.success(()) let cancellable = receiveEvent( - .sfuEvent(.joinResponse(Stream_Video_Sfu_Event_JoinResponse())), + .joinResponse(Stream_Video_Sfu_Event_JoinResponse()), every: 0.3 ) @@ -510,7 +510,7 @@ final class WebRTCCoordinatorStateMachine_JoiningStageTests: XCTestCase, @unchec mockCoordinatorStack.webRTCAuthenticator.stubbedFunction[.waitForConnect] = Result.success(()) let eventCancellable = receiveEvent( - .sfuEvent(.joinResponse(Stream_Video_Sfu_Event_JoinResponse())), + .joinResponse(Stream_Video_Sfu_Event_JoinResponse()), every: 0.3 ) @@ -544,7 +544,7 @@ final class WebRTCCoordinatorStateMachine_JoiningStageTests: XCTestCase, @unchec mockCoordinatorStack.webRTCAuthenticator.stubbedFunction[.waitForConnect] = Result.success(()) let eventCancellable = receiveEvent( - .sfuEvent(.joinResponse(Stream_Video_Sfu_Event_JoinResponse())), + .joinResponse(Stream_Video_Sfu_Event_JoinResponse()), every: 0.3 ) @@ -582,7 +582,7 @@ final class WebRTCCoordinatorStateMachine_JoiningStageTests: XCTestCase, @unchec .set(sfuAdapter: mockCoordinatorStack.sfuStack.adapter) mockCoordinatorStack.webRTCAuthenticator.stubbedFunction[.waitForConnect] = Result.success(()) let cancellable = receiveEvent( - .sfuEvent(.joinResponse(Stream_Video_Sfu_Event_JoinResponse())), + .joinResponse(Stream_Video_Sfu_Event_JoinResponse()), every: 0.3 ) let start = Date() @@ -622,7 +622,7 @@ final class WebRTCCoordinatorStateMachine_JoiningStageTests: XCTestCase, @unchec .set(sfuAdapter: mockCoordinatorStack.sfuStack.adapter) mockCoordinatorStack.webRTCAuthenticator.stubbedFunction[.waitForConnect] = Result.success(()) let cancellable = receiveEvent( - .sfuEvent(.joinResponse(Stream_Video_Sfu_Event_JoinResponse())), + .joinResponse(Stream_Video_Sfu_Event_JoinResponse()), every: 0.3 ) let start = Date() @@ -706,11 +706,11 @@ final class WebRTCCoordinatorStateMachine_JoiningStageTests: XCTestCase, @unchec expectedTarget: .disconnected, subject: subject ) { [mockCoordinatorStack] _ in - let webSocketEngine = try XCTUnwrap(mockCoordinatorStack?.sfuStack.webSocket.mockEngine) + let webSocketEngine = try XCTUnwrap(mockCoordinatorStack?.sfuStack.webSocket) let request = try XCTUnwrap( webSocketEngine.recordedInputPayload( Stream_Video_Sfu_Event_SfuRequest.self, - for: .sendMessage + for: .send )?.first ) let sessionID = await mockCoordinatorStack?.coordinator.stateAdapter.sessionID @@ -845,7 +845,7 @@ final class WebRTCCoordinatorStateMachine_JoiningStageTests: XCTestCase, @unchec .set(sfuAdapter: mockCoordinatorStack.sfuStack.adapter) let cancellable = receiveEvent( - .sfuEvent(.joinResponse(Stream_Video_Sfu_Event_JoinResponse())), + .joinResponse(Stream_Video_Sfu_Event_JoinResponse()), every: 0.3 ) @@ -871,7 +871,7 @@ final class WebRTCCoordinatorStateMachine_JoiningStageTests: XCTestCase, @unchec .set(sfuAdapter: mockCoordinatorStack.sfuStack.adapter) let cancellable = receiveEvent( - .sfuEvent(.joinResponse(Stream_Video_Sfu_Event_JoinResponse())), + .joinResponse(Stream_Video_Sfu_Event_JoinResponse()), every: 0.3 ) @@ -880,11 +880,11 @@ final class WebRTCCoordinatorStateMachine_JoiningStageTests: XCTestCase, @unchec expectedTarget: .disconnected, subject: subject ) { [mockCoordinatorStack] _ in - let webSocketEngine = try XCTUnwrap(mockCoordinatorStack?.sfuStack.webSocket.mockEngine) + let webSocketEngine = try XCTUnwrap(mockCoordinatorStack?.sfuStack.webSocket) let requests = try XCTUnwrap( webSocketEngine.recordedInputPayload( Stream_Video_Sfu_Event_HealthCheckRequest.self, - for: .sendMessage + for: .send ) ) XCTAssertEqual(requests.count, 1) @@ -904,7 +904,7 @@ final class WebRTCCoordinatorStateMachine_JoiningStageTests: XCTestCase, @unchec var response = Stream_Video_Sfu_Event_JoinResponse() response.fastReconnectDeadlineSeconds = 22 let cancellable = receiveEvent( - .sfuEvent(.joinResponse(response)), + .joinResponse(response), every: 0.3 ) @@ -925,7 +925,7 @@ final class WebRTCCoordinatorStateMachine_JoiningStageTests: XCTestCase, @unchec .set(sfuAdapter: mockCoordinatorStack.sfuStack.adapter) mockCoordinatorStack.webRTCAuthenticator.stubbedFunction[.waitForConnect] = Result.success(()) let cancellable = receiveEvent( - .sfuEvent(.joinResponse(Stream_Video_Sfu_Event_JoinResponse())), + .joinResponse(Stream_Video_Sfu_Event_JoinResponse()), every: 0.3 ) @@ -951,7 +951,7 @@ final class WebRTCCoordinatorStateMachine_JoiningStageTests: XCTestCase, @unchec .set(sfuAdapter: mockCoordinatorStack.sfuStack.adapter) mockCoordinatorStack.webRTCAuthenticator.stubbedFunction[.waitForConnect] = Result.success(()) let cancellable = receiveEvent( - .sfuEvent(.joinResponse(Stream_Video_Sfu_Event_JoinResponse())), + .joinResponse(Stream_Video_Sfu_Event_JoinResponse()), every: 0.3 ) @@ -998,7 +998,7 @@ final class WebRTCCoordinatorStateMachine_JoiningStageTests: XCTestCase, @unchec participantBuilder(subject.context.isRejoiningFromSessionID) ] let cancellable = receiveEvent( - .sfuEvent(.joinResponse(response)), + .joinResponse(response), every: 0.3 ) @@ -1025,7 +1025,7 @@ final class WebRTCCoordinatorStateMachine_JoiningStageTests: XCTestCase, @unchec var response = Stream_Video_Sfu_Event_JoinResponse() response.fastReconnectDeadlineSeconds = 22 let cancellable = receiveEvent( - .sfuEvent(.joinResponse(response)), + .joinResponse(response), every: 0.3 ) @@ -1052,7 +1052,7 @@ final class WebRTCCoordinatorStateMachine_JoiningStageTests: XCTestCase, @unchec .set(sfuAdapter: mockCoordinatorStack.sfuStack.adapter) mockCoordinatorStack.webRTCAuthenticator.stubbedFunction[.waitForConnect] = Result.success(()) let cancellable = receiveEvent( - .sfuEvent(.joinResponse(Stream_Video_Sfu_Event_JoinResponse())), + .joinResponse(Stream_Video_Sfu_Event_JoinResponse()), every: 0.3 ) @@ -1150,11 +1150,11 @@ final class WebRTCCoordinatorStateMachine_JoiningStageTests: XCTestCase, @unchec expectedTarget: .disconnected, subject: subject ) { [mockCoordinatorStack] _ in - let webSocketEngine = try XCTUnwrap(mockCoordinatorStack?.sfuStack.webSocket.mockEngine) + let webSocketEngine = try XCTUnwrap(mockCoordinatorStack?.sfuStack.webSocket) let request = try XCTUnwrap( webSocketEngine.recordedInputPayload( Stream_Video_Sfu_Event_SfuRequest.self, - for: .sendMessage + for: .send )?.first ) let sessionID = await mockCoordinatorStack?.coordinator.stateAdapter.sessionID @@ -1227,7 +1227,7 @@ final class WebRTCCoordinatorStateMachine_JoiningStageTests: XCTestCase, @unchec participantBuilder() ] let cancellable = receiveEvent( - .sfuEvent(.joinResponse(response)), + .joinResponse(response), every: 0.3 ) @@ -1259,7 +1259,7 @@ final class WebRTCCoordinatorStateMachine_JoiningStageTests: XCTestCase, @unchec .set(sfuAdapter: mockCoordinatorStack.sfuStack.adapter) mockCoordinatorStack.webRTCAuthenticator.stubbedFunction[.waitForConnect] = Result.success(()) let cancellable = receiveEvent( - .sfuEvent(.joinResponse(Stream_Video_Sfu_Event_JoinResponse())), + .joinResponse(Stream_Video_Sfu_Event_JoinResponse()), every: 0.3 ) @@ -1351,11 +1351,11 @@ final class WebRTCCoordinatorStateMachine_JoiningStageTests: XCTestCase, @unchec expectedTarget: .disconnected, subject: subject ) { [mockCoordinatorStack] _ in - let webSocketEngine = try XCTUnwrap(mockCoordinatorStack?.sfuStack.webSocket.mockEngine) + let webSocketEngine = try XCTUnwrap(mockCoordinatorStack?.sfuStack.webSocket) let request = try XCTUnwrap( webSocketEngine.recordedInputPayload( Stream_Video_Sfu_Event_SfuRequest.self, - for: .sendMessage + for: .send )?.first ) let sessionID = await mockCoordinatorStack?.coordinator.stateAdapter.sessionID @@ -1492,7 +1492,7 @@ final class WebRTCCoordinatorStateMachine_JoiningStageTests: XCTestCase, @unchec .set(sfuAdapter: mockCoordinatorStack.sfuStack.adapter) let cancellable = receiveEvent( - .sfuEvent(.joinResponse(Stream_Video_Sfu_Event_JoinResponse())), + .joinResponse(Stream_Video_Sfu_Event_JoinResponse()), every: 0.3 ) @@ -1518,7 +1518,7 @@ final class WebRTCCoordinatorStateMachine_JoiningStageTests: XCTestCase, @unchec .set(sfuAdapter: mockCoordinatorStack.sfuStack.adapter) let cancellable = receiveEvent( - .sfuEvent(.joinResponse(Stream_Video_Sfu_Event_JoinResponse())), + .joinResponse(Stream_Video_Sfu_Event_JoinResponse()), every: 0.3 ) @@ -1527,11 +1527,11 @@ final class WebRTCCoordinatorStateMachine_JoiningStageTests: XCTestCase, @unchec expectedTarget: .disconnected, subject: subject ) { [mockCoordinatorStack] _ in - let webSocketEngine = try XCTUnwrap(mockCoordinatorStack?.sfuStack.webSocket.mockEngine) + let webSocketEngine = try XCTUnwrap(mockCoordinatorStack?.sfuStack.webSocket) let requests = try XCTUnwrap( webSocketEngine.recordedInputPayload( Stream_Video_Sfu_Event_HealthCheckRequest.self, - for: .sendMessage + for: .send ) ) XCTAssertEqual(requests.count, 1) @@ -1551,7 +1551,7 @@ final class WebRTCCoordinatorStateMachine_JoiningStageTests: XCTestCase, @unchec var response = Stream_Video_Sfu_Event_JoinResponse() response.fastReconnectDeadlineSeconds = 22 let cancellable = receiveEvent( - .sfuEvent(.joinResponse(response)), + .joinResponse(response), every: 0.3 ) @@ -1573,7 +1573,7 @@ final class WebRTCCoordinatorStateMachine_JoiningStageTests: XCTestCase, @unchec .set(sfuAdapter: mockCoordinatorStack.sfuStack.adapter) mockCoordinatorStack.webRTCAuthenticator.stubbedFunction[.waitForConnect] = Result.success(()) let cancellable = receiveEvent( - .sfuEvent(.joinResponse(Stream_Video_Sfu_Event_JoinResponse())), + .joinResponse(Stream_Video_Sfu_Event_JoinResponse()), every: 0.3 ) @@ -1600,7 +1600,7 @@ final class WebRTCCoordinatorStateMachine_JoiningStageTests: XCTestCase, @unchec .set(sfuAdapter: mockCoordinatorStack.sfuStack.adapter) mockCoordinatorStack.webRTCAuthenticator.stubbedFunction[.waitForConnect] = Result.success(()) let cancellable = receiveEvent( - .sfuEvent(.joinResponse(Stream_Video_Sfu_Event_JoinResponse())), + .joinResponse(Stream_Video_Sfu_Event_JoinResponse()), every: 0.3 ) @@ -1645,7 +1645,7 @@ final class WebRTCCoordinatorStateMachine_JoiningStageTests: XCTestCase, @unchec participantBuilder() ] let cancellable = receiveEvent( - .sfuEvent(.joinResponse(response)), + .joinResponse(response), every: 0.3 ) @@ -1673,7 +1673,7 @@ final class WebRTCCoordinatorStateMachine_JoiningStageTests: XCTestCase, @unchec var response = Stream_Video_Sfu_Event_JoinResponse() response.fastReconnectDeadlineSeconds = 22 let cancellable = receiveEvent( - .sfuEvent(.joinResponse(response)), + .joinResponse(response), every: 0.3 ) @@ -1701,7 +1701,7 @@ final class WebRTCCoordinatorStateMachine_JoiningStageTests: XCTestCase, @unchec .set(sfuAdapter: mockCoordinatorStack.sfuStack.adapter) mockCoordinatorStack.webRTCAuthenticator.stubbedFunction[.waitForConnect] = Result.success(()) let cancellable = receiveEvent( - .sfuEvent(.joinResponse(Stream_Video_Sfu_Event_JoinResponse())), + .joinResponse(Stream_Video_Sfu_Event_JoinResponse()), every: 0.3 ) @@ -1813,7 +1813,7 @@ final class WebRTCCoordinatorStateMachine_JoiningStageTests: XCTestCase, @unchec } private func receiveEvent( - _ event: WrappedEvent, + _ event: Stream_Video_Sfu_Event_SfuEvent.OneOf_EventPayload, every timeInterval: TimeInterval ) -> AnyCancellable { Foundation diff --git a/StreamVideoTests/WebRTC/v2/StateMachine/Stages/WebRTCCoordinatorStateMachine_LeavingStageTests.swift b/StreamVideoTests/WebRTC/v2/StateMachine/Stages/WebRTCCoordinatorStateMachine_LeavingStageTests.swift index 2b13c5b8a..13067f9a3 100644 --- a/StreamVideoTests/WebRTC/v2/StateMachine/Stages/WebRTCCoordinatorStateMachine_LeavingStageTests.swift +++ b/StreamVideoTests/WebRTC/v2/StateMachine/Stages/WebRTCCoordinatorStateMachine_LeavingStageTests.swift @@ -91,7 +91,7 @@ final class WebRTCCoordinatorStateMachine_LeavingStageTests: XCTestCase, @unchec await wait(for: 0.5) let webSocket = mockCoordinatorStack.sfuStack.webSocket - XCTAssertEqual(webSocket.mockEngine.timesCalled(.sendMessage), 0) + XCTAssertEqual(webSocket.timesCalled(.send), 0) XCTAssertEqual(webSocket.timesCalled(.disconnectAsync), 0) } @@ -117,19 +117,17 @@ final class WebRTCCoordinatorStateMachine_LeavingStageTests: XCTestCase, @unchec let webSocket = mockCoordinatorStack.sfuStack.webSocket XCTAssertEqual( webSocket - .mockEngine .recordedInputPayload( Stream_Video_Sfu_Event_SfuRequest.self, - for: .sendMessage + for: .send )?.first?.leaveCallRequest.sessionID, sessionId ) XCTAssertEqual( webSocket - .mockEngine .recordedInputPayload( Stream_Video_Sfu_Event_SfuRequest.self, - for: .sendMessage + for: .send )?.first?.leaveCallRequest.reason, "" ) @@ -160,19 +158,17 @@ final class WebRTCCoordinatorStateMachine_LeavingStageTests: XCTestCase, @unchec let webSocket = mockCoordinatorStack.sfuStack.webSocket XCTAssertEqual( webSocket - .mockEngine .recordedInputPayload( Stream_Video_Sfu_Event_SfuRequest.self, - for: .sendMessage + for: .send )?.first?.leaveCallRequest.sessionID, sessionId ) XCTAssertEqual( webSocket - .mockEngine .recordedInputPayload( Stream_Video_Sfu_Event_SfuRequest.self, - for: .sendMessage + for: .send )?.first?.leaveCallRequest.reason, reason ) diff --git a/StreamVideoTests/WebRTC/v2/StateMachine/Stages/WebRTCCoordinatorStateMachine_RejoiningStageTests.swift b/StreamVideoTests/WebRTC/v2/StateMachine/Stages/WebRTCCoordinatorStateMachine_RejoiningStageTests.swift index cd267d190..02456263d 100644 --- a/StreamVideoTests/WebRTC/v2/StateMachine/Stages/WebRTCCoordinatorStateMachine_RejoiningStageTests.swift +++ b/StreamVideoTests/WebRTC/v2/StateMachine/Stages/WebRTCCoordinatorStateMachine_RejoiningStageTests.swift @@ -73,7 +73,7 @@ final class WebRTCCoordinatorStateMachine_RejoiningStageTests: XCTestCase, @unch await wait(for: 0.5) let webSocket = mockCoordinatorStack.sfuStack.webSocket - XCTAssertEqual(webSocket.mockEngine.timesCalled(.sendMessage), 0) + XCTAssertEqual(webSocket.timesCalled(.send), 0) XCTAssertEqual(webSocket.timesCalled(.disconnectAsync), 0) } @@ -99,10 +99,9 @@ final class WebRTCCoordinatorStateMachine_RejoiningStageTests: XCTestCase, @unch let webSocket = mockCoordinatorStack.sfuStack.webSocket XCTAssertEqual( webSocket - .mockEngine .recordedInputPayload( Stream_Video_Sfu_Event_SfuRequest.self, - for: .sendMessage + for: .send )?.first?.leaveCallRequest.sessionID, sessionId ) diff --git a/StreamVideoTests/WebRTC/v2/WebRTCMigrationStatusObserver_Tests.swift b/StreamVideoTests/WebRTC/v2/WebRTCMigrationStatusObserver_Tests.swift index 4c98d10eb..de17306ce 100644 --- a/StreamVideoTests/WebRTC/v2/WebRTCMigrationStatusObserver_Tests.swift +++ b/StreamVideoTests/WebRTC/v2/WebRTCMigrationStatusObserver_Tests.swift @@ -37,7 +37,7 @@ final class WebRTCMigrationStatusObserver_Tests: XCTestCase, @unchecked Sendable await self.wait(for: 0.5) self .sfuStack - .receiveEvent(.sfuEvent(.participantMigrationComplete(Stream_Video_Sfu_Event_ParticipantMigrationComplete()))) + .receiveEvent(.participantMigrationComplete(Stream_Video_Sfu_Event_ParticipantMigrationComplete())) } try await group.waitForAll() diff --git a/StreamVideoTests/WebRTC/v2/WebRTCSFUFullObserver_Tests.swift b/StreamVideoTests/WebRTC/v2/WebRTCSFUFullObserver_Tests.swift index de1e96118..fe3128a33 100644 --- a/StreamVideoTests/WebRTC/v2/WebRTCSFUFullObserver_Tests.swift +++ b/StreamVideoTests/WebRTC/v2/WebRTCSFUFullObserver_Tests.swift @@ -43,7 +43,7 @@ final class WebRTCSFUFullObserver_Tests: XCTestCase, @unchecked Sendable { var error = Stream_Video_Sfu_Event_Error() error.error.code = .sfuFull - sfuStack.receiveEvent(.sfuEvent(.error(error))) + sfuStack.receiveEvent(.error(error)) await fulfillment(of: [transitionExpectation], timeout: defaultTimeout) XCTAssertEqual(try XCTUnwrap(subject.shouldMigrateError).error.code, .sfuFull) @@ -52,7 +52,7 @@ final class WebRTCSFUFullObserver_Tests: XCTestCase, @unchecked Sendable { func test_publisher_whenErrorCodeIsNotSFUFull_doesNotEmitOrUpdateState() async { var error = Stream_Video_Sfu_Event_Error() error.error.code = .participantSignalLost - sfuStack.receiveEvent(.sfuEvent(.error(error))) + sfuStack.receiveEvent(.error(error)) await wait(for: 0.2) XCTAssertNil(subject.shouldMigrateError) diff --git a/StreamVideoTests/WebSocketClient/BackgroundTaskScheduler_Tests.swift b/StreamVideoTests/WebSocketClient/BackgroundTaskScheduler_Tests.swift deleted file mode 100644 index 603563397..000000000 --- a/StreamVideoTests/WebSocketClient/BackgroundTaskScheduler_Tests.swift +++ /dev/null @@ -1,47 +0,0 @@ -// -// Copyright © 2026 Stream.io Inc. All rights reserved. -// - -@testable import StreamVideo -import XCTest - -#if os(iOS) -@MainActor -final class IOSBackgroundTaskScheduler_Tests: XCTestCase, @unchecked Sendable { - func test_notifications_foreground() { - // Given - let scheduler = IOSBackgroundTaskScheduler() - var calledBackground = false - var calledForeground = false - scheduler.startListeningForAppStateUpdates( - onEnteringBackground: { calledBackground = true }, - onEnteringForeground: { calledForeground = true } - ) - - // When - NotificationCenter.default.post(name: UIApplication.didBecomeActiveNotification, object: nil) - - // Then - XCTAssertTrue(calledForeground) - XCTAssertFalse(calledBackground) - } - - func test_notifications_background() { - // Given - let scheduler = IOSBackgroundTaskScheduler() - var calledBackground = false - var calledForeground = false - scheduler.startListeningForAppStateUpdates( - onEnteringBackground: { calledBackground = true }, - onEnteringForeground: { calledForeground = true } - ) - - // When - NotificationCenter.default.post(name: UIApplication.didEnterBackgroundNotification, object: nil) - - // Then - XCTAssertFalse(calledForeground) - XCTAssertTrue(calledBackground) - } -} -#endif diff --git a/StreamVideoTests/WebSocketClient/EventNotificationCenter_Tests.swift b/StreamVideoTests/WebSocketClient/EventNotificationCenter_Tests.swift deleted file mode 100644 index b0e08a1a8..000000000 --- a/StreamVideoTests/WebSocketClient/EventNotificationCenter_Tests.swift +++ /dev/null @@ -1,221 +0,0 @@ -// -// Copyright © 2026 Stream.io Inc. All rights reserved. -// - -import CoreData -@testable import StreamVideo -import XCTest - -final class EventNotificationCenter_Tests: XCTestCase, @unchecked Sendable { - - func test_init_worksCorrectly() { - // Create middlewares - let middlewares: [EventMiddleware_Mock] = [ - .init(), - .init(), - .init() - ] - - // Create notification center with middlewares - let center = EventNotificationCenter() - middlewares.forEach(center.add) - - // Assert middlewares are assigned correctly - let centerMiddlewares = center.middlewares as! [EventMiddleware_Mock] - XCTAssertEqual(middlewares.count, centerMiddlewares.count) - zip(middlewares, centerMiddlewares).forEach { - XCTAssertTrue($0.0 === $0.1) - } - } - - func test_addMiddleware_worksCorrectly() { - // Create middlewares - let middlewares: [EventMiddleware_Mock] = [ - .init(), - .init(), - .init() - ] - - // Create notification center without any middlewares - let center = EventNotificationCenter() - - // Add middlewares via `add` method - middlewares.forEach(center.add) - - // Assert middlewares are assigned correctly - let centerMiddlewares = center.middlewares as! [EventMiddleware_Mock] - XCTAssertEqual(middlewares.count, centerMiddlewares.count) - zip(middlewares, centerMiddlewares).forEach { - XCTAssertTrue($0.0 === $0.1) - } - } - - func test_eventIsNotPublished_ifSomeMiddlewareDoesNotForwardEvent() { - let consumingMiddleware = EventMiddleware_Mock { _ in nil } - - // Create a notification center with blocking middleware - let center = EventNotificationCenter() - center.add(middleware: consumingMiddleware) - - // Create event logger to check published events - let eventLogger = EventLogger(center) - - // Simulate incoming event - center.process(.internalEvent(TestEvent())) - - // Assert event is published as it is - AssertAsync.staysTrue(eventLogger.equatableEvents.isEmpty) - } - - func test_eventIsPublishedAsItIs_ifThereAreNoMiddlewares() { - // Create a notification center without any middlewares - let center = EventNotificationCenter() - - // Create event logger to check published events - let eventLogger = EventLogger(center) - - // Simulate incoming event - let event = TestEvent() - center.process(.internalEvent(event)) - - let expectation = expectation(description: "wait expectation") - expectation.isInverted = true - wait(for: [expectation], timeout: 1) - - // Assert event is published as it is - AssertAsync.willBeEqual(eventLogger.events as? [TestEvent], [event]) - } - - func test_process_whenShouldPostEventsIsTrue_eventsArePosted() { - // Create a notification center with just a forwarding middleware - let center = EventNotificationCenter() - - // Create event logger to check published events - let eventLogger = EventLogger(center) - - // Simulate incoming events - let events = [TestEvent(), TestEvent(), TestEvent(), TestEvent()] - - // Feed events that should be posted and catch the completion - nonisolated(unsafe) var completionCalled = false - center.process(events.map { .internalEvent($0) }, postNotifications: true) { - completionCalled = true - } - - // Wait completion to be called - AssertAsync.willBeTrue(completionCalled) - - // Assert events are posted. - XCTAssertEqual(eventLogger.events as! [TestEvent], events) - } - - func test_process_whenShouldPostEventsIsFalse_eventsAreNotPosted() { - // Create a notification center with just a forwarding middleware - let center = EventNotificationCenter() - - // Create event logger to check published events - let eventLogger = EventLogger(center) - - // Simulate incoming events - let events = [TestEvent(), TestEvent(), TestEvent(), TestEvent()] - - // Feed events that should not be posted and catch the completion - nonisolated(unsafe) var completionCalled = false - center.process(events.map { .internalEvent($0) }, postNotifications: false) { - completionCalled = true - } - - // Wait completion to be called - AssertAsync.willBeTrue(completionCalled) - - // Assert events are not posted. - XCTAssertTrue(eventLogger.events.isEmpty) - } - - func test_process_postsEventsOnPostingQueue() { - // Create notification center - let center = EventNotificationCenter() - - // Assign mock events posting queue - let mockQueueUUID = UUID() - let mockQueue = DispatchQueue.testQueue(withId: mockQueueUUID) - center.eventPostingQueue = mockQueue - - // Create test event - let testEvent = TestEvent() - - // Setup event observer - nonisolated(unsafe) var observerTriggered = false - - let observer = center.addObserver( - forName: .NewEventReceived, - object: nil, - queue: nil - ) { notification in - guard let wrappedEvent = notification.event else { return } - - switch wrappedEvent { - case let .internalEvent(event): - // Assert notification contains test event - XCTAssertEqual(event as? TestEvent, testEvent) - // Assert notification is posted on correct queue - XCTAssertTrue(DispatchQueue.isTestQueue(withId: mockQueueUUID)) - default: - break - } - - observerTriggered = true - } - - // Process test event and post when processing is completed - center.process([.internalEvent(testEvent)], postNotifications: true) - - let expectation = expectation(description: "wait expectation") - expectation.isInverted = true - wait(for: [expectation], timeout: 1) - - // Wait for observer to be called - AssertAsync.willBeTrue(observerTriggered) - - // Remove observer - center.removeObserver(observer) - } - - func test_process_whenOriginalEventIsSwapped_newEventIsProcessedFurther() { - // Create incoming event - let originalEvent = TestEvent() - - // Create event that will be returned instead of incoming event - let outputEvent = TestEvent() - - // Create a notification center - let center = EventNotificationCenter() - - // Create event logger to check published events - let eventLogger = EventLogger(center) - - // Add event swapping middleware - center.add(middleware: EventMiddleware_Mock { event in - // Assert expected event is received - if case let .internalEvent(event) = event { - XCTAssertEqual(event as? TestEvent, originalEvent) - // Swap to outputEvent - return .internalEvent(outputEvent) - } - return event - }) - - // Start processing of original event - center.process(.internalEvent(originalEvent), postNotification: true) - - let expectation = expectation(description: "wait expectation") - expectation.isInverted = true - wait(for: [expectation], timeout: 1) - - // Assert event returned from middleware is posted - AssertAsync.willBeEqual( - eventLogger.events.compactMap { $0 as? TestEvent }, - [outputEvent] - ) - } -} diff --git a/StreamVideoTests/WebSocketClient/WebSocketClient_Tests.swift b/StreamVideoTests/WebSocketClient/WebSocketClient_Tests.swift deleted file mode 100644 index c7d37251d..000000000 --- a/StreamVideoTests/WebSocketClient/WebSocketClient_Tests.swift +++ /dev/null @@ -1,429 +0,0 @@ -// -// Copyright © 2026 Stream.io Inc. All rights reserved. -// - -@testable import StreamVideo -import XCTest - -final class WebSocketClient_Tests: XCTestCase, @unchecked Sendable { - // The longest time WebSocket waits to reconnect. - let maxReconnectTimeout: VirtualTime.Seconds = 25 - - var webSocketClient: WebSocketClient! - - var time: VirtualTime! - private var decoder: EventDecoder_Mock! - var engine: WebSocketEngine_Mock? { webSocketClient.engine as? WebSocketEngine_Mock } - var connectionId: String! - var user: User! - var pingController: WebSocketPingController_Mock { webSocketClient.pingController as! WebSocketPingController_Mock } - var eventsBatcher: EventBatcher_Mock { webSocketClient.eventsBatcher as! EventBatcher_Mock } - - var eventNotificationCenter: EventNotificationCenter_Mock! - private var eventNotificationCenterMiddleware: EventMiddleware_Mock! - - private let connectURL = URL(string: "http://example.com/ws")! - - private let createdAt = Date() - private lazy var healthCheckInfo = HealthCheckInfo( - coordinatorHealthCheck: HealthCheckEvent( - connectionId: connectionId, - createdAt: createdAt - ) - ) - - override func setUp() { - super.setUp() - - time = VirtualTime() - VirtualTimeTimer.time = time - - decoder = EventDecoder_Mock() - - eventNotificationCenter = EventNotificationCenter_Mock() - eventNotificationCenterMiddleware = EventMiddleware_Mock() - eventNotificationCenter.add(middleware: eventNotificationCenterMiddleware) - - var environment = WebSocketClient.Environment.mock - environment.timerType = VirtualTimeTimer.self - - webSocketClient = WebSocketClient( - sessionConfiguration: .ephemeral, - eventDecoder: decoder, - eventNotificationCenter: eventNotificationCenter, - webSocketClientType: .coordinator, - environment: environment, - connectURL: connectURL - ) - - connectionId = UUID().uuidString - user = User(id: "test_user_\(UUID().uuidString)") - } - - override func tearDown() { - AssertAsync.canBeReleased(&webSocketClient) - AssertAsync.canBeReleased(&eventNotificationCenter) - AssertAsync.canBeReleased(&eventNotificationCenterMiddleware) - - webSocketClient = nil - eventNotificationCenter = nil - eventNotificationCenterMiddleware = nil - VirtualTimeTimer.invalidate() - time = nil - decoder = nil - connectionId = nil - user = nil - - super.tearDown() - } - - // MARK: - Setup - - func test_webSocketClient_isInstantiatedInCorrectState() { - XCTAssertNil(webSocketClient.engine) - } - - func test_engine_isReused_ifRequestIsNotChanged() { - // Simulate connect to trigger engine creation or reuse. - webSocketClient.connect() - // Save currently existed engine. - let oldEngine = webSocketClient.engine - // Disconnect the client. - webSocketClient.disconnect {} - - // Simulate connect to trigger engine creation or reuse. - webSocketClient.connect() - - // Assert engine is reused since the connect request is not changed. - XCTAssertTrue(oldEngine === webSocketClient.engine) - } - - // MARK: - Connection tests - - func test_connectionFlow() { - assert(webSocketClient.connectionState == .initialized) - - webSocketClient.connect() - XCTAssertEqual(webSocketClient.connectionState, .connecting) - - AssertAsync { - Assert.willBeEqual(self.engine!.connect_calledCount, 1) - } - - // Simulate the engine is connected and check the connection state is updated - engine!.simulateConnectionSuccess() - AssertAsync.willBeEqual(webSocketClient.connectionState, .authenticating) - - // Simulate a health check event is received and the connection state is updated - decoder - .decodedEvent = - .success(.coordinatorEvent(.typeHealthCheckEvent(HealthCheckEvent(connectionId: connectionId, createdAt: createdAt)))) - engine!.simulateMessageReceived() - - AssertAsync.willBeEqual(webSocketClient.connectionState, .connected(healthCheckInfo: healthCheckInfo)) - } - - func test_callingConnect_whenAlreadyConnected_hasNoEffect() { - // Simulate connection - test_connectionFlow() - - assert(webSocketClient.connectionState == .connected(healthCheckInfo: healthCheckInfo)) - assert(engine!.connect_calledCount == 1) - - // Call connect and assert it has no effect - webSocketClient.connect() - AssertAsync { - Assert.staysTrue(self.engine!.connect_calledCount == 1) - Assert.staysTrue(self.webSocketClient.connectionState == .connected(healthCheckInfo: self.healthCheckInfo)) - } - } - - func test_disconnect_callsEngine() { - // Simulate connection - test_connectionFlow() - - assert(webSocketClient.connectionState == .connected(healthCheckInfo: healthCheckInfo)) - assert(engine!.disconnect_calledCount == 0) - - // Call `disconnect`, it should change connection state and call `disconnect` on the engine - let source: WebSocketConnectionState.DisconnectionSource = .userInitiated - webSocketClient.disconnect(source: source) {} - - // Assert disconnect is called - AssertAsync.willBeEqual(engine!.disconnect_calledCount, 1) - XCTAssertEqual(engine!.disconnect_closeCode, .normalClosure) - } - - func test_whenConnectedAndEngineDisconnectsWithServerError_itIsTreatedAsServerInitiatedDisconnect() { - // Simulate connection - test_connectionFlow() - - // Simulate the engine disconnecting with server error - let errorPayload = ErrorPayload( - code: .unique, - message: .unique, - statusCode: .unique - ) - let engineError = WebSocketEngineError( - reason: UUID().uuidString, - code: 0, - engineError: errorPayload - ) - engine!.simulateDisconnect(engineError) - - // Assert state is disconnected with `systemInitiated` source - XCTAssertEqual( - webSocketClient.connectionState, - .disconnected(source: .serverInitiated(error: ClientError.WebSocket(with: engineError))) - ) - } - - func test_disconnect_propagatesDisconnectionSource() { - // Simulate connection - test_connectionFlow() - - let testCases: [WebSocketConnectionState.DisconnectionSource] = [ - .userInitiated, - .systemInitiated, - .serverInitiated(error: nil), - .serverInitiated(error: .init(.unique)) - ] - - for source in testCases { - engine?.disconnect_calledCount = 0 - - // Call `disconnect` with the given source - webSocketClient.disconnect(source: source) {} - - // Assert connection state is changed to disconnecting respecting the source - XCTAssertEqual(webSocketClient.connectionState, .disconnecting(source: source)) - - // Assert disconnect is called - AssertAsync.willBeEqual(engine!.disconnect_calledCount, 1) - - // Simulate engine disconnection - engine!.simulateDisconnect() - - // Assert state is `disconnected` with the correct source - AssertAsync.willBeEqual(webSocketClient.connectionState, .disconnected(source: source)) - } - } - - func test_connectionState_afterDecodingError() { - // Simulate connection - test_connectionFlow() - - decoder.decodedEvent = .failure( - ClientError.UnsupportedEventType() - ) - engine!.simulateMessageReceived() - - AssertAsync.staysEqual(webSocketClient.connectionState, .connected(healthCheckInfo: healthCheckInfo)) - } - - // MARK: - Ping Controller - - func test_webSocketPingController_connectionStateDidChange_calledWhenConnectionChanges() { - test_connectionFlow() - AssertAsync.willBeEqual( - pingController.connectionStateDidChange_connectionStates, - [ - .connecting, - .authenticating, - .connected(healthCheckInfo: healthCheckInfo), - .connected(healthCheckInfo: healthCheckInfo) - ] - ) - } - - func test_webSocketPingController_ping_callsEngineWithPing() { - // Simulate connection to make sure web socket engine exists - test_connectionFlow() - // Reset the counter - engine!.sendPing_calledCount = 0 - - pingController.delegate?.sendPing() - AssertAsync.willBeEqual(engine!.sendPing_calledCount, 1) - } - - func test_pongReceived_callsPingController_pongReceived() { - // Simulate connection to make sure web socket engine exists - test_connectionFlow() - assert(pingController.pongReceivedCount == 1) - - // Simulate a health check (pong) event is received - decoder.decodedEvent = .success( - .coordinatorEvent( - .typeHealthCheckEvent( - HealthCheckEvent(connectionId: connectionId, createdAt: createdAt) - ) - ) - ) - engine!.simulateMessageReceived() - - AssertAsync.willBeEqual(pingController.pongReceivedCount, 2) - } - - func test_webSocketPingController_disconnectOnNoPongReceived_coordinatorUsesNormalClosure() { - // Simulate connection to make sure web socket engine exists - test_connectionFlow() - - assert(engine!.disconnect_calledCount == 0) - - pingController.delegate?.disconnectOnNoPongReceived() - - AssertAsync { - Assert.willBeEqual(self.webSocketClient.connectionState, .disconnecting(source: .noPongReceived)) - Assert.willBeEqual(self.engine!.disconnect_calledCount, 1) - } - XCTAssertEqual(engine!.disconnect_closeCode, .normalClosure) - } - - func test_webSocketPingController_disconnectOnNoPongReceived_sfuUsesUnhealthyConnectionClosure() { - var environment = WebSocketClient.Environment.mock - environment.timerType = VirtualTimeTimer.self - webSocketClient = WebSocketClient( - sessionConfiguration: .ephemeral, - eventDecoder: decoder, - eventNotificationCenter: eventNotificationCenter, - webSocketClientType: .sfu, - environment: environment, - connectURL: connectURL - ) - webSocketClient.connect() - AssertAsync.willBeEqual(engine!.connect_calledCount, 1) - - pingController.delegate?.disconnectOnNoPongReceived() - - AssertAsync { - Assert.willBeEqual(self.webSocketClient.connectionState, .disconnecting(source: .noPongReceived)) - Assert.willBeEqual(self.engine!.disconnect_calledCount, 1) - } - XCTAssertEqual(engine!.disconnect_closeCode?.rawValue, 4001) - } - - // MARK: - Event handling tests - - func test_whenCoordinatorHealthCheckEventComes_itGetProcessedSilentlyWithoutBatching() throws { - // Connect the web-socket client - webSocketClient.connect() - - // Wait for engine to be called - AssertAsync.willBeEqual(engine!.connect_calledCount, 1) - - // Simulate engine established connection - engine!.simulateConnectionSuccess() - - // Wait for the connection state to be propagated to web-socket client - AssertAsync.willBeEqual(webSocketClient.connectionState, .authenticating) - - // Simulate received health check event - let healthCheckEvent = HealthCheckEvent(connectionId: .unique, createdAt: createdAt) - decoder.decodedEvent = .success(.coordinatorEvent(.typeHealthCheckEvent(healthCheckEvent))) - engine!.simulateMessageReceived() - - // Assert healtch check event does not get batched - let batchedEvents = eventsBatcher.mock_append.calls.map(\.asEquatable) - XCTAssertFalse(batchedEvents.contains(healthCheckEvent.asEquatable)) - - // Assert health check event was processed - let (_, postNotification, _) = try XCTUnwrap( - eventNotificationCenter.mock_process.calls.first(where: { events, _, _ in - events.first?.healthcheck() != nil - }) - ) - - // Assert health check events was not posted - XCTAssertFalse(postNotification) - } - - func test_whenSFUHealthCheckEventComes_itGetProcessedSilentlyWithoutBatching() { - let receiptExpectation = expectation(description: "HealthCheck event received") - let cancellable = webSocketClient - .eventSubject - .filter { $0.name.contains("healthCheck") } - .sink { _ in receiptExpectation.fulfill() } - - // Connect the web-socket client - webSocketClient.connect() - - // Wait for engine to be called - AssertAsync.willBeEqual(engine!.connect_calledCount, 1) - - // Simulate engine established connection - engine!.simulateConnectionSuccess() - - // Wait for the connection state to be propagated to web-socket client - AssertAsync.willBeEqual(webSocketClient.connectionState, .authenticating) - - // Simulate received health check event - decoder.decodedEvent = .success(.sfuEvent(.healthCheckResponse(.init()))) - engine!.simulateMessageReceived() - - wait(for: [receiptExpectation], timeout: defaultTimeout) - cancellable.cancel() - } - - func test_whenNonHealthCheckEventComes_getsBatchedAndPostedAfterProcessing() throws { - // Simulate connection - test_connectionFlow() - - // Clear state - eventsBatcher.mock_append.calls.removeAll() - eventNotificationCenter.mock_process.calls.removeAll() - - // Simulate incoming event - let incomingEvent = CustomVideoEvent( - callCid: "default:123", - createdAt: Date(), - custom: [:], - user: UserResponse( - blockedUserIds: [], - createdAt: Date(), - custom: [:], - id: "test", - language: "en", - role: "user", - teams: [], - updatedAt: Date() - ) - ) - decoder.decodedEvent = .success(.coordinatorEvent(.typeCustomVideoEvent(incomingEvent))) - engine!.simulateMessageReceived() - - // Assert event gets batched - XCTAssertEqual( - eventsBatcher.mock_append.calls.map(\.asEquatable), - [WrappedEvent.coordinatorEvent(.typeCustomVideoEvent(incomingEvent)).asEquatable] - ) - - // Assert incoming event get processed and posted - let (events, postNotifications, completion) = try XCTUnwrap(eventNotificationCenter.mock_process.calls.first) - XCTAssertEqual(events.map(\.asEquatable), [WrappedEvent.coordinatorEvent(.typeCustomVideoEvent(incomingEvent)).asEquatable]) - XCTAssertTrue(postNotifications) - XCTAssertNotNil(completion) - } - - func test_whenDisconnectHappens_immidiateBatchedEventsProcessingIsTriggered() { - // Simulate connection - test_connectionFlow() - - // Assert `processImmediately` was not triggered - XCTAssertFalse(eventsBatcher.mock_processImmediately.called) - - // Simulate disconnection - let expectation = expectation(description: "disconnect completion") - webSocketClient.disconnect { - expectation.fulfill() - } - - // Assert `processImmediately` is triggered - AssertAsync.willBeTrue(eventsBatcher.mock_processImmediately.called) - - // Simulate batch processing completion - eventsBatcher.mock_processImmediately.calls.last?() - - // Assert completion called - wait(for: [expectation], timeout: defaultTimeout) - } -} diff --git a/StreamVideoTests/WebSocketClient/WebSocketConnectionState_Tests.swift b/StreamVideoTests/WebSocketClient/WebSocketConnectionState_Tests.swift deleted file mode 100644 index ea981a425..000000000 --- a/StreamVideoTests/WebSocketClient/WebSocketConnectionState_Tests.swift +++ /dev/null @@ -1,142 +0,0 @@ -// -// Copyright © 2026 Stream.io Inc. All rights reserved. -// - -@testable import StreamVideo -import XCTest - -final class WebSocketConnectionState_Tests: XCTestCase, @unchecked Sendable { - // MARK: - Server error - - func test_disconnectionSource_serverError() { - // Create test error - let testError = ClientError(with: TestError()) - - // Create pairs of disconnection source and expected server error - let testCases: [(WebSocketConnectionState.DisconnectionSource, ClientError?)] = [ - (.userInitiated, nil), - (.systemInitiated, nil), - (.noPongReceived, nil), - (.serverInitiated(error: nil), nil), - (.serverInitiated(error: testError), testError) - ] - - // Iterate pairs - testCases.forEach { source, serverError in - // Assert returned server error matches expected one - XCTAssertEqual(source.serverError, serverError) - } - } - - // MARK: - Automatic reconnection - - func test_isAutomaticReconnectionEnabled_whenNotDisconnected_returnsFalse() { - // Create array of connection states excluding disconnected state - let connectionStates: [WebSocketConnectionState] = [ - .initialized, - .connecting, - .connected(healthCheckInfo: HealthCheckInfo(coordinatorHealthCheck: nil, sfuHealthCheck: nil)), - .disconnecting(source: .userInitiated), - .disconnecting(source: .systemInitiated), - .disconnecting(source: .noPongReceived), - .disconnecting(source: .serverInitiated(error: nil)) - ] - - // Iterate conneciton states - for state in connectionStates { - // Assert `isAutomaticReconnectionEnabled` returns false - XCTAssertFalse(state.isAutomaticReconnectionEnabled) - } - } - - func test_isAutomaticReconnectionEnabled_whenDisconnectedBySystem_returnsTrue() { - // Create disconnected state initated by the sytem - let state: WebSocketConnectionState = .disconnected(source: .systemInitiated) - - // Assert `isAutomaticReconnectionEnabled` returns true - XCTAssertTrue(state.isAutomaticReconnectionEnabled) - } - - func test_isAutomaticReconnectionEnabled_whenDisconnectedWithNoPongReceived_returnsTrue() { - // Create disconnected state when pong does not come - let state: WebSocketConnectionState = .disconnected(source: .noPongReceived) - - // Assert `isAutomaticReconnectionEnabled` returns true - XCTAssertTrue(state.isAutomaticReconnectionEnabled) - } - - func test_isAutomaticReconnectionEnabled_whenDisconnectedByServerWithoutError_returnsTrue() { - // Create disconnected state initiated by the server without any error - let state: WebSocketConnectionState = .disconnected(source: .serverInitiated(error: nil)) - - // Assert `isAutomaticReconnectionEnabled` returns true - XCTAssertTrue(state.isAutomaticReconnectionEnabled) - } - - func test_isAutomaticReconnectionEnabled_whenDisconnectedByServerWithRandomError_returnsTrue() { - // Create disconnected state intiated by the server with random error - let state: WebSocketConnectionState = .disconnected(source: .serverInitiated(error: ClientError(.unique))) - - // Assert `isAutomaticReconnectionEnabled` returns true - XCTAssertTrue(state.isAutomaticReconnectionEnabled) - } - - func test_isAutomaticReconnectionEnabled_whenDisconnectedByUser_returnsFalse() { - // Create disconnected state initated by the user - let state: WebSocketConnectionState = .disconnected(source: .userInitiated) - - // Assert `isAutomaticReconnectionEnabled` returns false - XCTAssertFalse(state.isAutomaticReconnectionEnabled) - } - - func test_isAutomaticReconnectionEnabled_whenDisconnectedByServerWithInvalidTokenError_returnsFalse() { - // Create invalid token error - let invalidTokenError = ErrorPayload( - code: ClosedRange.tokenInvalidErrorCodes.lowerBound, - message: .unique, - statusCode: .unique - ) - - // Create disconnected state intiated by the server with invalid token error - let state: WebSocketConnectionState = .disconnected( - source: .serverInitiated(error: ClientError(with: invalidTokenError)) - ) - - // Assert `isAutomaticReconnectionEnabled` returns false - XCTAssertFalse(state.isAutomaticReconnectionEnabled) - } - - func test_isAutomaticReconnectionEnabled_whenDisconnectedByServerWithClientError_returnsFalse() { - // Create client error - let clientError = ErrorPayload( - code: .unique, - message: .unique, - statusCode: ClosedRange.clientErrorCodes.lowerBound - ) - - // Create disconnected state intiated by the server with client error - let state: WebSocketConnectionState = .disconnected( - source: .serverInitiated(error: ClientError(with: clientError)) - ) - - // Assert `isAutomaticReconnectionEnabled` returns false - XCTAssertFalse(state.isAutomaticReconnectionEnabled) - } - - func test_isAutomaticReconnectionEnabled_whenDisconnectedByServerWithStopError_returnsFalse() { - // Create stop error - let stopError = WebSocketEngineError( - reason: .unique, - code: WebSocketEngineError.stopErrorCode, - engineError: nil - ) - - // Create disconnected state intiated by the server with stop error - let state: WebSocketConnectionState = .disconnected( - source: .serverInitiated(error: ClientError(with: stopError)) - ) - - // Assert `isAutomaticReconnectionEnabled` returns false - XCTAssertFalse(state.isAutomaticReconnectionEnabled) - } -} diff --git a/StreamVideoTests/WebSocketClient/WebSocketPingController_Tests.swift b/StreamVideoTests/WebSocketClient/WebSocketPingController_Tests.swift deleted file mode 100644 index a034c093a..000000000 --- a/StreamVideoTests/WebSocketClient/WebSocketPingController_Tests.swift +++ /dev/null @@ -1,90 +0,0 @@ -// -// Copyright © 2026 Stream.io Inc. All rights reserved. -// - -@testable import StreamVideo -import XCTest - -final class WebSocketPingController_Tests: XCTestCase, @unchecked Sendable { - var time: VirtualTime! - var pingController: WebSocketPingController! - private var delegate: WebSocketPingController_Delegate! - - override func setUp() { - super.setUp() - time = VirtualTime() - VirtualTimeTimer.time = time - pingController = .init( - timerType: VirtualTimeTimer.self, - timerQueue: .main, - webSocketClientType: .coordinator - ) - - delegate = WebSocketPingController_Delegate() - pingController.delegate = delegate - } - - override func tearDown() { - VirtualTimeTimer.invalidate() - time = nil - pingController = nil - delegate = nil - super.tearDown() - } - - func test_sendPing_called_whenTheConnectionIsConnected() throws { - assert(delegate.sendPing_calledCount == 0) - - // Check `sendPing` is not called when the connection is not connected - time.run(numberOfSeconds: WebSocketPingController.pingTimeInterval + 1) - XCTAssertEqual(delegate.sendPing_calledCount, 0) - - // Set the connection state as connected - pingController.connectionStateDidChange(.connected(healthCheckInfo: HealthCheckInfo())) - - // Simulate time passed 3x pingTimeInterval (+1 for margin errors) - time.run(numberOfSeconds: 3 * (WebSocketPingController.pingTimeInterval + 1)) - XCTAssertEqual(delegate.sendPing_calledCount, 3) - - let oldPingCount = delegate.sendPing_calledCount - - // Set the connection state to not connected and check `sendPing` is no longer called - pingController.connectionStateDidChange(.authenticating) - time.run(numberOfSeconds: 3 * (WebSocketPingController.pingTimeInterval + 1)) - XCTAssertEqual(delegate.sendPing_calledCount, oldPingCount) - } - - func test_disconnectOnNoPongReceived_called_whenNoPongReceived() throws { - // Set the connection state as connected - pingController.connectionStateDidChange(.connected(healthCheckInfo: HealthCheckInfo())) - - assert(delegate.sendPing_calledCount == 0) - - // Simulate time passing and wait for `sendPing` call - while delegate.sendPing_calledCount != 1 { - time.run(numberOfSeconds: 1) - } - - // Simulate pong received - pingController.pongReceived() - - // Simulate time passed pongTimeoutTimeInterval + 1 and check disconnectOnNoPongReceived wasn't called - assert(delegate.disconnectOnNoPongReceived_calledCount == 0) - time.run(numberOfSeconds: WebSocketPingController.pongTimeoutTimeInterval + 1) - XCTAssertEqual(delegate.disconnectOnNoPongReceived_calledCount, 0) - - assert(delegate.sendPing_calledCount == 1) - - // Simulate time passing and wait for another `sendPing` call - while delegate.sendPing_calledCount != 2 { - time.run(numberOfSeconds: 1) - } - - // Simulate time passed pongTimeoutTimeInterval + 1 without receiving a pong - assert(delegate.disconnectOnNoPongReceived_calledCount == 0) - time.run(numberOfSeconds: WebSocketPingController.pongTimeoutTimeInterval + 1) - - // `disconnectOnNoPongReceived` should be called - XCTAssertEqual(delegate.disconnectOnNoPongReceived_calledCount, 1) - } -} diff --git a/StreamVideoTests/WebSockets/Client/CoordinatorWebSocket_Tests.swift b/StreamVideoTests/WebSockets/Client/CoordinatorWebSocket_Tests.swift new file mode 100644 index 000000000..851a5eca8 --- /dev/null +++ b/StreamVideoTests/WebSockets/Client/CoordinatorWebSocket_Tests.swift @@ -0,0 +1,110 @@ +// +// Copyright © 2026 Stream.io Inc. All rights reserved. +// + +import Combine +import Foundation +import StreamCore +@testable import StreamVideo +import XCTest + +final class CoordinatorWebSocket_Tests: XCTestCase, @unchecked Sendable { + + func test_connectionFailure_reconnectsAutomatically() async { + let eventNotificationCenter = DefaultEventNotificationCenter() + let subject = CoordinatorWebSocket( + url: URL(string: "ws://127.0.0.1:1")!, + eventNotificationCenter: eventNotificationCenter, + sessionConfiguration: .ephemeral, + connectPayloadProvider: { nil }, + hasActiveCall: { true } + ) + let reconnected = expectation(description: "WebSocket reconnects") + + var connectingCount = 0 + let cancellable = subject + .connectionStatePublisher + .sink { + guard $0 == .connecting else { return } + connectingCount += 1 + if connectingCount == 2 { + reconnected.fulfill() + } + } + + subject.connect() + await fulfillment(of: [reconnected], timeout: defaultTimeout) + await subject.disconnect() + + XCTAssertEqual(connectingCount, 2) + cancellable.cancel() + } + + func test_callKitReconnectionPolicy_reflectsActiveCall() { + XCTAssertFalse( + CallKitReconnectionPolicy { false }.canBeReconnected() + ) + XCTAssertTrue( + CallKitReconnectionPolicy { true }.canBeReconnected() + ) + } + + func test_connectionStatus_tokenErrors_mapsToConnecting() { + for code in [2, 40, 41, 42, 43] { + let error = ClientError( + with: APIError( + code: code, + message: "token error", + statusCode: 401 + ) + ) + let state = WebSocketConnectionState.disconnected( + source: .serverInitiated(error: error) + ) + + XCTAssertEqual( + ConnectionStatus( + videoWebSocketConnectionState: state + ), + .connecting, + "APIError code \(code)" + ) + } + } + + func test_connectionStatus_automaticReconnection_mapsToConnecting() { + let state = WebSocketConnectionState.disconnected( + source: .systemInitiated + ) + + XCTAssertEqual( + ConnectionStatus( + videoWebSocketConnectionState: state + ), + .connecting + ) + } + + func test_connectionStatus_nonRecoverableError_preservesError() { + let error = ClientError( + with: APIError( + code: 100, + message: "non-recoverable", + statusCode: 400 + ) + ) + let state = WebSocketConnectionState.disconnected( + source: .serverInitiated(error: error) + ) + + guard case let .disconnected(receivedError) = + ConnectionStatus( + videoWebSocketConnectionState: state + ) + else { + return XCTFail("Expected a disconnected status.") + } + + XCTAssertTrue(receivedError === error) + } +} diff --git a/StreamVideoTests/WebSockets/Client/DefaultRetryStrategyTests.swift b/StreamVideoTests/WebSockets/Client/DefaultRetryStrategyTests.swift index f710928b3..11ad8d8cf 100644 --- a/StreamVideoTests/WebSockets/Client/DefaultRetryStrategyTests.swift +++ b/StreamVideoTests/WebSockets/Client/DefaultRetryStrategyTests.swift @@ -6,6 +6,7 @@ import XCTest final class DefaultRetryStrategyTests: XCTestCase, @unchecked Sendable { + private let expectedStreamVideoMaximumReconnectionDelay: TimeInterval = 25 private var subject: DefaultRetryStrategy! = .init() override func tearDown() { @@ -142,7 +143,13 @@ final class DefaultRetryStrategyTests: XCTestCase, @unchecked Sendable { let delay = subject.nextRetryDelay() XCTAssertGreaterThanOrEqual(delay, 0.25) - XCTAssertLessThanOrEqual(delay, min(0.5 + Double(i * 2), DefaultRetryStrategy.maximumReconnectionDelay)) + XCTAssertLessThanOrEqual( + delay, + min( + 0.5 + Double(i * 2), + expectedStreamVideoMaximumReconnectionDelay + ) + ) } } @@ -153,6 +160,9 @@ final class DefaultRetryStrategyTests: XCTestCase, @unchecked Sendable { let delay = subject.nextRetryDelay() - XCTAssertLessThanOrEqual(delay, DefaultRetryStrategy.maximumReconnectionDelay) + XCTAssertLessThanOrEqual( + delay, + expectedStreamVideoMaximumReconnectionDelay + ) } } diff --git a/StreamVideoTests/WebSockets/Events/EventTests.swift b/StreamVideoTests/WebSockets/Events/EventTests.swift index 65825cff0..cc76aabea 100644 --- a/StreamVideoTests/WebSockets/Events/EventTests.swift +++ b/StreamVideoTests/WebSockets/Events/EventTests.swift @@ -3,6 +3,7 @@ // import Foundation +import StreamCore @testable import StreamVideo import XCTest @@ -55,16 +56,21 @@ final class EventTests: XCTestCase, @unchecked Sendable { } func test_unwrap_isWrappedButNotCoordinatorEvent_returnsExpected() { - struct TestEvent: Event {} - let wrappedEvent = WrappedEvent.internalEvent(TestEvent()) + let wrappedEvent = WrappedEvent.internalEvent( + HealthCheckEvent( + connectionId: UUID().uuidString, + createdAt: Date() + ) + ) XCTAssertNil(wrappedEvent.unwrap()) } func test_unwrap_isUnknownEvent_returnsExpected() { - struct TestEvent: Event {} - - let subject = TestEvent() + let subject = HealthCheckEvent( + connectionId: UUID().uuidString, + createdAt: Date() + ) XCTAssertNil(subject.unwrap()) } @@ -95,10 +101,77 @@ final class EventTests: XCTestCase, @unchecked Sendable { } func test_forCall_isNotVideoEvent_returnsFalse() { - struct TestEvent: Event {} - - let subject = TestEvent() + let subject = HealthCheckEvent( + connectionId: UUID().uuidString, + createdAt: Date() + ) XCTAssertFalse(subject.forCall(cid: "123")) } + + // MARK: - StreamCore compatibility + + func test_generatedCoordinatorEvent_isStreamCoreEvent() { + let subject: any Event = HealthCheckEvent( + connectionId: UUID().uuidString, + createdAt: Date() + ) + + XCTAssertNil(subject.healthcheck()) + XCTAssertNil(subject.error()) + } + + func test_wrappedCoordinatorHealthCheck_returnsCoreHealthCheckInfo() { + let connectionId = UUID().uuidString + let subject: any Event = WrappedEvent.coordinatorEvent( + .typeHealthCheckEvent( + .init( + connectionId: connectionId, + createdAt: Date() + ) + ) + ) + + XCTAssertEqual( + subject.healthcheck(), + HealthCheckInfo(connectionId: connectionId) + ) + } + + func test_wrappedCoordinatorError_returnsAPIError() { + let event = ConnectionErrorEvent( + connectionId: UUID().uuidString, + createdAt: Date(), + error: .init( + code: 1, + details: [], + duration: "", + message: "test", + moreInfo: "", + statusCode: 400 + ) + ) + let subject: any Event = WrappedEvent.coordinatorEvent( + .typeConnectionErrorEvent(event) + ) + + guard let result = subject.error() else { + return XCTFail("Expected an API error.") + } + XCTAssertTrue((result as AnyObject) === event.error) + } + + func test_sendableProtobufEvents_serializeThroughStreamCoreProtocol() throws { + var request = Stream_Video_Sfu_Event_SfuRequest() + request.healthCheckRequest = + Stream_Video_Sfu_Event_HealthCheckRequest() + let subjects: [any SendableEvent] = [ + request, + Stream_Video_Sfu_Event_HealthCheckRequest() + ] + + for subject in subjects { + XCTAssertNoThrow(try subject.serializedData(partial: false)) + } + } } diff --git a/StreamVideoTests/WebSockets/Events/JsonEventDecoder_Tests.swift b/StreamVideoTests/WebSockets/Events/JsonEventDecoder_Tests.swift new file mode 100644 index 000000000..ad8424303 --- /dev/null +++ b/StreamVideoTests/WebSockets/Events/JsonEventDecoder_Tests.swift @@ -0,0 +1,128 @@ +// +// Copyright © 2026 Stream.io Inc. All rights reserved. +// + +import Foundation +@testable import StreamVideo +import XCTest + +final class JsonEventDecoder_Tests: XCTestCase, @unchecked Sendable { + + private var subject: JsonEventDecoder! + + override func setUp() { + super.setUp() + subject = JsonEventDecoder() + } + + override func tearDown() { + subject = nil + super.tearDown() + } + + func test_decode_regularCoordinatorEvent_returnsWrappedEvent() throws { + let expected = CallModerationBlurEvent( + callCid: "default:123", + createdAt: Date(timeIntervalSince1970: 0), + custom: [:], + userId: "user" + ) + + let result = try subject.decode( + from: JSONEncoder.streamCore.encode(expected) + ) + + guard + let wrappedEvent = result as? WrappedEvent, + case let .coordinatorEvent( + .typeCallModerationBlurEvent(event) + ) = wrappedEvent + else { + return XCTFail("Expected a wrapped moderation blur event.") + } + XCTAssertEqual(event, expected) + } + + func test_decode_connectedEvent_returnsConnectionId() throws { + let connectionId = UUID().uuidString + let date = Date(timeIntervalSince1970: 0) + let event = ConnectedEvent( + connectionId: connectionId, + createdAt: date, + me: .init( + createdAt: date, + custom: [:], + devices: [], + id: "user", + language: "en", + role: "user", + teams: [], + updatedAt: date + ) + ) + + let result = try subject.decode( + from: JSONEncoder.streamCore.encode(event) + ) + + XCTAssertEqual( + result.healthcheck(), + .init(connectionId: connectionId) + ) + } + + func test_decode_healthCheckEvent_returnsConnectionId() throws { + let connectionId = UUID().uuidString + let event = HealthCheckEvent( + connectionId: connectionId, + createdAt: Date(timeIntervalSince1970: 0) + ) + + let result = try subject.decode( + from: JSONEncoder.streamCore.encode(event) + ) + + XCTAssertEqual( + result.healthcheck(), + .init(connectionId: connectionId) + ) + } + + func test_decode_connectionErrorEvent_forwardsError() throws { + let expected = APIError( + code: 1, + details: [], + duration: "", + message: "test", + moreInfo: "", + statusCode: 400 + ) + let event = ConnectionErrorEvent( + connectionId: UUID().uuidString, + createdAt: Date(timeIntervalSince1970: 0), + error: expected + ) + + let result = try subject.decode( + from: JSONEncoder.streamCore.encode(event) + ) + + XCTAssertEqual(result.error() as? APIError, expected) + } + + func test_decode_malformedPayload_throwsDecodingError() { + XCTAssertThrowsError(try subject.decode(from: Data("{".utf8))) { + XCTAssertTrue($0 is DecodingError) + } + } + + func test_decode_unsupportedEventType_throwsTypeMismatch() { + let data = Data(#"{"type":"unsupported"}"#.utf8) + + XCTAssertThrowsError(try subject.decode(from: data)) { error in + guard case DecodingError.typeMismatch = error else { + return XCTFail("Expected a type mismatch error.") + } + } + } +} diff --git a/StreamVideoTests/WebSockets/Events/WSEventsMiddleware_Tests.swift b/StreamVideoTests/WebSockets/Events/WSEventsMiddleware_Tests.swift new file mode 100644 index 000000000..04e00d4ce --- /dev/null +++ b/StreamVideoTests/WebSockets/Events/WSEventsMiddleware_Tests.swift @@ -0,0 +1,179 @@ +// +// Copyright © 2026 Stream.io Inc. All rights reserved. +// + +import Combine +import Foundation +import StreamCore +@testable import StreamVideo +import XCTest + +final class WSEventsMiddleware_Tests: XCTestCase, @unchecked Sendable { + + private var subject: WSEventsMiddleware! + + override func setUp() { + super.setUp() + subject = WSEventsMiddleware() + } + + override func tearDown() { + subject = nil + super.tearDown() + } + + func test_handle_wrappedEvent_fansOutToAllSubscribers() { + let expectation = expectation(description: "All subscribers") + expectation.expectedFulfillmentCount = 2 + let first = SubscriberSpy { _ in expectation.fulfill() } + let second = SubscriberSpy { _ in expectation.fulfill() } + subject.add(subscriber: first) + subject.add(subscriber: second) + + _ = subject.handle(event: makeEvent()) + + wait(for: [expectation], timeout: defaultTimeout) + } + + func test_handle_wrappedEvent_notifiesStreamVideoLast() { + let recorder = Recorder() + let expectation = expectation(description: "All subscribers") + expectation.expectedFulfillmentCount = 2 + let streamVideo = StreamVideo( + apiKey: "key", + user: .anonymous, + token: StreamVideo.mockToken, + videoConfig: .dummy(), + autoConnectOnInit: false + ) + let subscriber = SubscriberSpy { _ in + recorder.append("subscriber") + expectation.fulfill() + } + let cancellable = streamVideo.eventPublisher().sink { _ in + recorder.append("streamVideo") + expectation.fulfill() + } + subject.add(subscriber: streamVideo) + subject.add(subscriber: subscriber) + + _ = subject.handle(event: makeEvent()) + + wait(for: [expectation], timeout: defaultTimeout) + XCTAssertEqual(recorder.values, ["subscriber", "streamVideo"]) + cancellable.cancel() + } + + func test_process_consumedEvent_doesNotNotifySubscribers() { + let expectation = expectation(description: "No subscriber") + expectation.isInverted = true + let subscriber = SubscriberSpy { _ in expectation.fulfill() } + subject.add(subscriber: subscriber) + let center = DefaultEventNotificationCenter() + center.add(middlewares: [ConsumingMiddleware(), subject]) + + center.process(makeEvent(), postNotification: false) + + wait(for: [expectation], timeout: 0.1) + } + + func test_process_sharedCenter_deliversCoordinatorEventOnce() { + let recorder = Recorder() + let expectation = expectation(description: "Coordinator event") + let subscriber = SubscriberSpy { + recorder.append($0) + expectation.fulfill() + } + subject.add(subscriber: subscriber) + let center = DefaultEventNotificationCenter() + center.add(middlewares: [subject]) + + center.process(makeEvent(), postNotification: false) + + wait(for: [expectation], timeout: defaultTimeout) + AssertAsync.staysTrue(recorder.values.count == 1) + } + + func test_handle_internalConnectionEvents_notifiesSubscribers() { + let recorder = Recorder() + let expectation = expectation(description: "Connection events") + expectation.expectedFulfillmentCount = 2 + let subscriber = SubscriberSpy { event in + recorder.append(event.name) + expectation.fulfill() + } + subject.add(subscriber: subscriber) + + _ = subject.handle(event: WrappedEvent.internalEvent(WSConnected())) + _ = subject.handle(event: WrappedEvent.internalEvent(WSDisconnected())) + + wait(for: [expectation], timeout: defaultTimeout) + XCTAssertEqual( + recorder.values, + ["internal: WSConnected", "internal: WSDisconnected"] + ) + } + + func test_handle_nonWrappedEvent_returnsEventWithoutNotifyingSubscribers() { + let expectation = expectation(description: "No subscriber") + expectation.isInverted = true + let subscriber = SubscriberSpy { _ in expectation.fulfill() } + subject.add(subscriber: subscriber) + let event = NonWrappedEvent(id: .unique) + + let result = subject.handle(event: event) + + XCTAssertEqual((result as? NonWrappedEvent)?.id, event.id) + wait(for: [expectation], timeout: 0.1) + } + + private func makeEvent() -> WrappedEvent { + .coordinatorEvent( + .typeHealthCheckEvent( + .init( + connectionId: UUID().uuidString, + createdAt: Date() + ) + ) + ) + } +} + +private struct NonWrappedEvent: Event { + let id: String +} + +private final class SubscriberSpy: WSEventsSubscriber, @unchecked Sendable { + private let handler: @Sendable (WrappedEvent) -> Void + + init(handler: @escaping @Sendable (WrappedEvent) -> Void) { + self.handler = handler + } + + func onEvent(_ event: WrappedEvent) async { + handler(event) + } +} + +private struct ConsumingMiddleware: EventMiddleware { + func handle(event: Event) -> Event? { + nil + } +} + +private final class Recorder: @unchecked Sendable { + private let lock = NSLock() + private var storage: [Value] = [] + + var values: [Value] { + lock.lock() + defer { lock.unlock() } + return storage + } + + func append(_ value: Value) { + lock.lock() + defer { lock.unlock() } + storage.append(value) + } +} diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 0de3e5b2e..f17e42272 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -802,12 +802,18 @@ private_lane :frameworks_sizes do stream_video_uikit_size_kb = (stream_video_uikit_size + stream_video_swiftui_size) / 1024.0 stream_web_rtc_size = File.size("#{frameworks_path}/StreamWebRTC.framework/StreamWebRTC") stream_web_rtc_size_kb = stream_web_rtc_size / 1024.0 + stream_core_framework = + Dir.glob("#{frameworks_path}/StreamCore{,_*}.framework").fetch(0) + stream_core_binary = + File.join(stream_core_framework, File.basename(stream_core_framework, '.framework')) + stream_core_size_kb = File.size(stream_core_binary) / 1024.0 { StreamVideo: stream_video_size_kb.round(0), StreamVideoSwiftUI: stream_video_swiftui_size_kb.round(0), StreamVideoUIKit: stream_video_uikit_size_kb.round(0), - StreamWebRTC: stream_web_rtc_size_kb.round(0) + StreamWebRTC: stream_web_rtc_size_kb.round(0), + StreamCore: stream_core_size_kb.round(0) } end