From a7967ed0f75c7dcfd776722012570add31c549e8 Mon Sep 17 00:00:00 2001 From: Ilias Pavlidakis Date: Mon, 14 Oct 2024 13:51:52 +0300 Subject: [PATCH 1/3] [Feature]Disconnection timeout during call --- CHANGELOG.md | 1 + DemoApp/Sources/AppDelegate.swift | 2 +- .../Feedback/DemoFeedbackView.swift | 14 ++ .../project.pbxproj | 12 +- ...wift => 22-manual-quality-selection.swift} | 0 .../23-network-disruption.swift | 146 ++++++++++++++ Sources/StreamVideo/Call.swift | 27 +++ Sources/StreamVideo/CallState.swift | 9 + .../Controllers/CallController.swift | 12 +- Sources/StreamVideo/Errors/Errors.swift | 5 +- .../WebRTCCoordinator+Disconnected.swift | 47 +++++ .../Stages/WebRTCCoordinator+Stage.swift | 1 + .../WebRTC/v2/WebRTCCoordinator.swift | 4 + StreamVideo.xcodeproj/project.pbxproj | 4 + StreamVideoTests/Call/Call_Tests.swift | 77 ++------ .../Controllers/CallController_Tests.swift | 21 ++- .../Mock/MockCallController.swift | 81 ++++++++ ...rStateMachine_DisconnectedStageTests.swift | 9 + .../WebRTC/v2/WebRTCCoorindator_Tests.swift | 13 ++ .../iOS/05-ui-cookbook/23-network-disruption | 178 ++++++++++++++++++ 20 files changed, 593 insertions(+), 70 deletions(-) rename DocumentationTests/DocumentationTests/DocumentationTests/05-ui-cookbook/{21-manual-quality-selection.swift => 22-manual-quality-selection.swift} (100%) create mode 100644 DocumentationTests/DocumentationTests/DocumentationTests/05-ui-cookbook/23-network-disruption.swift create mode 100644 StreamVideoTests/Mock/MockCallController.swift create mode 100644 docusaurus/docs/iOS/05-ui-cookbook/23-network-disruption diff --git a/CHANGELOG.md b/CHANGELOG.md index c5973f11a..8ab6bb0e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### ✅ Added - You can now provide the incoming video quality setting for some or all participants [#571](https://github.com/GetStream/stream-video-swift/pull/571) +- You can now set the time a user can remain in the call - after their connection disrupted - while waiting for their network connection to recover [#573](https://github.com/GetStream/stream-video-swift/pull/573) # [1.13.0](https://github.com/GetStream/stream-video-swift/releases/tag/1.13.0) _October 08, 2024_ diff --git a/DemoApp/Sources/AppDelegate.swift b/DemoApp/Sources/AppDelegate.swift index ca5c2ff89..cbca07534 100644 --- a/DemoApp/Sources/AppDelegate.swift +++ b/DemoApp/Sources/AppDelegate.swift @@ -99,7 +99,7 @@ class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDele .current() .requestAuthorization(options: [.alert, .sound, .badge]) { granted, _ in if granted { - DispatchQueue.main.async { + Task { @MainActor in UIApplication.shared.registerForRemoteNotifications() } } diff --git a/DemoApp/Sources/Components/Feedback/DemoFeedbackView.swift b/DemoApp/Sources/Components/Feedback/DemoFeedbackView.swift index d15ed51cd..cd4d049fb 100644 --- a/DemoApp/Sources/Components/Feedback/DemoFeedbackView.swift +++ b/DemoApp/Sources/Components/Feedback/DemoFeedbackView.swift @@ -16,6 +16,7 @@ struct DemoFeedbackView: View { @State private var comment: String = "" @State private var rating: Int = 5 @State private var isSubmitting = false + @State private var toast: Toast? private weak var call: Call? private var dismiss: () -> Void @@ -121,6 +122,19 @@ struct DemoFeedbackView: View { .padding(.horizontal) } .withModalNavigationBar(title: "", closeAction: dismiss) + .toastView(toast: $toast) + .onAppear { checkIfDisconnectionErrorIsAvailable() } + } + + // MARK: - Private helpers + + func checkIfDisconnectionErrorIsAvailable() { + if call?.state.disconnectionError is ClientError.NetworkNotAvailable { + toast = .init( + style: .error, + message: "Your call was ended because it seems your internet connection is down." + ) + } } } diff --git a/DocumentationTests/DocumentationTests/DocumentationTests.xcodeproj/project.pbxproj b/DocumentationTests/DocumentationTests/DocumentationTests.xcodeproj/project.pbxproj index 0d1737d9c..9b1c53bee 100644 --- a/DocumentationTests/DocumentationTests/DocumentationTests.xcodeproj/project.pbxproj +++ b/DocumentationTests/DocumentationTests/DocumentationTests.xcodeproj/project.pbxproj @@ -16,10 +16,11 @@ 400D91D12B63DEA200EBA47D /* 04-camera-and-microphone.swift in Sources */ = {isa = PBXBuildFile; fileRef = 400D91D02B63DEA200EBA47D /* 04-camera-and-microphone.swift */; }; 400D91D32B63DFA500EBA47D /* 06-querying-calls.swift in Sources */ = {isa = PBXBuildFile; fileRef = 400D91D22B63DFA500EBA47D /* 06-querying-calls.swift */; }; 400D91D52B63E27300EBA47D /* 07-dependency-injection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 400D91D42B63E27300EBA47D /* 07-dependency-injection.swift */; }; - 4029E95E2CB94EAE00E1D571 /* 21-manual-quality-selection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4029E95D2CB94EA700E1D571 /* 21-manual-quality-selection.swift */; }; + 4029E95E2CB94EAE00E1D571 /* 22-manual-quality-selection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4029E95D2CB94EA700E1D571 /* 22-manual-quality-selection.swift */; }; 404CAED82B8E3874007087BC /* 06-apply-video-filters.swift in Sources */ = {isa = PBXBuildFile; fileRef = 404CAED72B8E3874007087BC /* 06-apply-video-filters.swift */; }; 4068C1252B67C056006B0BEE /* 03-callkit-integration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4068C1242B67C056006B0BEE /* 03-callkit-integration.swift */; }; 408CE0F52BD91B490052EC3A /* 19-transcriptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 408CE0F42BD91B490052EC3A /* 19-transcriptions.swift */; }; + 409774B02CC19F5500E0D3EE /* 23-network-disruption.swift in Sources */ = {isa = PBXBuildFile; fileRef = 409774AF2CC19F4900E0D3EE /* 23-network-disruption.swift */; }; 409C39692B67CC5C0090044C /* 04-screensharing.swift in Sources */ = {isa = PBXBuildFile; fileRef = 409C39682B67CC5C0090044C /* 04-screensharing.swift */; }; 409C396B2B67CD0B0090044C /* 05-picture-in-picture.swift in Sources */ = {isa = PBXBuildFile; fileRef = 409C396A2B67CD0B0090044C /* 05-picture-in-picture.swift */; }; 409C396D2B67CD780090044C /* 08-recording.swift in Sources */ = {isa = PBXBuildFile; fileRef = 409C396C2B67CD780090044C /* 08-recording.swift */; }; @@ -94,10 +95,11 @@ 400D91D02B63DEA200EBA47D /* 04-camera-and-microphone.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "04-camera-and-microphone.swift"; sourceTree = ""; }; 400D91D22B63DFA500EBA47D /* 06-querying-calls.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "06-querying-calls.swift"; sourceTree = ""; }; 400D91D42B63E27300EBA47D /* 07-dependency-injection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "07-dependency-injection.swift"; sourceTree = ""; }; - 4029E95D2CB94EA700E1D571 /* 21-manual-quality-selection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "21-manual-quality-selection.swift"; sourceTree = ""; }; + 4029E95D2CB94EA700E1D571 /* 22-manual-quality-selection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "22-manual-quality-selection.swift"; sourceTree = ""; }; 404CAED72B8E3874007087BC /* 06-apply-video-filters.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "06-apply-video-filters.swift"; sourceTree = ""; }; 4068C1242B67C056006B0BEE /* 03-callkit-integration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "03-callkit-integration.swift"; sourceTree = ""; }; 408CE0F42BD91B490052EC3A /* 19-transcriptions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "19-transcriptions.swift"; sourceTree = ""; }; + 409774AF2CC19F4900E0D3EE /* 23-network-disruption.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "23-network-disruption.swift"; sourceTree = ""; }; 409C39682B67CC5C0090044C /* 04-screensharing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "04-screensharing.swift"; sourceTree = ""; }; 409C396A2B67CD0B0090044C /* 05-picture-in-picture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "05-picture-in-picture.swift"; sourceTree = ""; }; 409C396C2B67CD780090044C /* 08-recording.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "08-recording.swift"; sourceTree = ""; }; @@ -274,7 +276,8 @@ 408CE0F42BD91B490052EC3A /* 19-transcriptions.swift */, 40F18B8F2BEBC97F00ADF76E /* 18-call-quality-rating.swift */, 40F290AC2BDFB3CA00DCF136 /* 20-noise-cancellation.swift */, - 4029E95D2CB94EA700E1D571 /* 21-manual-quality-selection.swift */, + 4029E95D2CB94EA700E1D571 /* 22-manual-quality-selection.swift */, + 409774AF2CC19F4900E0D3EE /* 23-network-disruption.swift */, ); path = "05-ui-cookbook"; sourceTree = ""; @@ -465,12 +468,13 @@ 4068C1252B67C056006B0BEE /* 03-callkit-integration.swift in Sources */, 40FFDC762B63F7D6004DA7A2 /* ChatGloballyUsedVariables.swift in Sources */, 84BA15AE2CA2EF420018DC51 /* 07-querying-call-members.swift in Sources */, + 409774B02CC19F5500E0D3EE /* 23-network-disruption.swift in Sources */, 40FFDC672B63F430004DA7A2 /* 04-connection-quality-indicator.swift in Sources */, 40B468982B67B6DF009B5B3E /* 01-deeplinking.swift in Sources */, 40FFDC3B2B63E493004DA7A2 /* 10-view-slots.swift in Sources */, 40FFDC442B63E95D004DA7A2 /* 14-swiftui-vs-uikit.swift in Sources */, 40FFDC872B63FEAE004DA7A2 /* 05-incoming-call.swift in Sources */, - 4029E95E2CB94EAE00E1D571 /* 21-manual-quality-selection.swift in Sources */, + 4029E95E2CB94EAE00E1D571 /* 22-manual-quality-selection.swift in Sources */, 40FFDC942B6401CC004DA7A2 /* 07-video-fallback.swift in Sources */, 400D91C72B63D96800EBA47D /* 03-quickstart.swift in Sources */, 40FFDC9E2B64063D004DA7A2 /* 12-connection-unstable.swift in Sources */, diff --git a/DocumentationTests/DocumentationTests/DocumentationTests/05-ui-cookbook/21-manual-quality-selection.swift b/DocumentationTests/DocumentationTests/DocumentationTests/05-ui-cookbook/22-manual-quality-selection.swift similarity index 100% rename from DocumentationTests/DocumentationTests/DocumentationTests/05-ui-cookbook/21-manual-quality-selection.swift rename to DocumentationTests/DocumentationTests/DocumentationTests/05-ui-cookbook/22-manual-quality-selection.swift diff --git a/DocumentationTests/DocumentationTests/DocumentationTests/05-ui-cookbook/23-network-disruption.swift b/DocumentationTests/DocumentationTests/DocumentationTests/05-ui-cookbook/23-network-disruption.swift new file mode 100644 index 000000000..c24b0d2cf --- /dev/null +++ b/DocumentationTests/DocumentationTests/DocumentationTests/05-ui-cookbook/23-network-disruption.swift @@ -0,0 +1,146 @@ +import StreamVideo +import StreamVideoSwiftUI +import SwiftUI +import Combine + +@MainActor +fileprivate func content() { + container { + let call = streamVideo.call(callType: "default", callId: callId) + + // Set the disconnection timeout to 60 seconds + call.setDisconnectionTimeout(60) + } + + container { + struct DemoFeedbackView: View { + + @Environment(\.openURL) private var openURL + @Injected(\.appearance) private var appearance + + @State private var email: String = "" + @State private var comment: String = "" + @State private var rating: Int = 5 + @State private var isSubmitting = false + @State private var toast: Toast? + + private weak var call: Call? + private var dismiss: () -> Void + private var isSubmitEnabled: Bool { !email.isEmpty && !isSubmitting } + + init(_ call: Call, dismiss: @escaping () -> Void) { + self.call = call + self.dismiss = dismiss + } + + var body: some View { + ScrollView { + VStack(spacing: 32) { + Image("feedbackLogo") + + VStack(spacing: 8) { + Text("How is your call going?") + .font(appearance.fonts.headline) + .foregroundColor(appearance.colors.text) + .lineLimit(1) + + Text("All feedback is celebrated!") + .font(appearance.fonts.subheadline) + .foregroundColor(.init(appearance.colors.textLowEmphasis)) + .lineLimit(2) + } + .frame(maxWidth: .infinity, alignment: .center) + .multilineTextAlignment(.center) + + VStack(spacing: 27) { + VStack(spacing: 16) { + TextField( + "Email Address *", + text: $email + ) + .textFieldStyle(DemoTextfieldStyle()) + + DemoTextEditor(text: $comment, placeholder: "Message") + } + + HStack { + Text("Rate Quality") + .font(appearance.fonts.body) + .foregroundColor(.init(appearance.colors.textLowEmphasis)) + .frame(maxWidth: .infinity, alignment: .leading) + + DemoStarRatingView(rating: $rating) + } + } + + HStack { + Button { + resignFirstResponder() + openURL(.init(string: "https://getstream.io/video/#contact")!) + } label: { + Text("Contact Us") + } + .frame(maxWidth: .infinity) + .foregroundColor(appearance.colors.text) + .padding(.vertical, 4) + .clipShape(Capsule()) + .overlay(Capsule().stroke(Color(appearance.colors.textLowEmphasis), lineWidth: 1)) + + Button { + resignFirstResponder() + isSubmitting = true + Task { + do { + try await call?.collectUserFeedback( + rating: rating, + reason: """ + \(email) + \(comment) + """ + ) + Task { @MainActor in + dismiss() + } + isSubmitting = false + } catch { + log.error(error) + dismiss() + isSubmitting = false + } + } + } label: { + if isSubmitting { + ProgressView() + } else { + Text("Submit") + } + } + .frame(maxWidth: .infinity) + .foregroundColor(appearance.colors.text) + .padding(.vertical, 4) + .background(isSubmitEnabled ? appearance.colors.accentBlue : appearance.colors.lightGray) + .disabled(!isSubmitEnabled) + .clipShape(Capsule()) + } + + Spacer() + } + .padding(.horizontal) + } + .toastView(toast: $toast) + .onAppear { checkIfDisconnectionErrorIsAvailable() } + } + + // MARK: - Private helpers + + func checkIfDisconnectionErrorIsAvailable() { + if call?.state.disconnectionError is ClientError.NetworkNotAvailable { + toast = .init( + style: .error, + message: "Your call was ended because it seems your internet connection is down." + ) + } + } + } + } +} diff --git a/Sources/StreamVideo/Call.swift b/Sources/StreamVideo/Call.swift index 315744972..a39e9873b 100644 --- a/Sources/StreamVideo/Call.swift +++ b/Sources/StreamVideo/Call.swift @@ -1153,6 +1153,29 @@ public class Call: @unchecked Sendable, WSEventsSubscriber { await callController.setIncomingVideoQualitySettings(value) } + /// Sets the disconnection timeout for a user who has temporarily lost connection. + /// + /// This method defines the duration a user, who has already joined the call, can remain + /// in a disconnected state due to temporary internet issues. If the user’s connection + /// remains disrupted beyond the specified timeout period, they will be dropped from the call. + /// This timeout helps ensure that users with unstable connections do not stay in the call + /// indefinitely if they cannot reconnect. + /// + /// - Parameters: + /// - timeout: The time interval, in seconds, that specifies how long a user can stay + /// disconnected before being removed from the call. For example, if the + /// timeout is set to 60 seconds, the user will be dropped from the call if + /// they do not reconnect within that timeframe. Defaults to 0, where the user will + /// remain in the disconnected state until their connection restores or the user hangs + /// up manually. + /// + /// - Important: This mechanism is critical in managing users with unstable internet + /// connections, ensuring that temporary network issues are handled gracefully + /// but long-term disconnections result in the user being removed from the call. + public func setDisconnectionTimeout(_ timeout: TimeInterval) { + callController.setDisconnectionTimeout(timeout) + } + // MARK: - Internal internal func update(reconnectionStatus: ReconnectionStatus) { @@ -1190,8 +1213,12 @@ public class Call: @unchecked Sendable, WSEventsSubscriber { } } + @MainActor func transitionDueToError(_ error: Error) { do { + if stateMachine.currentStage.id == .joined { + state.disconnectionError = error + } try stateMachine.transition(.error(self, error: error)) } catch { log.error(error) diff --git a/Sources/StreamVideo/CallState.swift b/Sources/StreamVideo/CallState.swift index a014d37af..3fc52dea2 100644 --- a/Sources/StreamVideo/CallState.swift +++ b/Sources/StreamVideo/CallState.swift @@ -130,6 +130,15 @@ public class CallState: ObservableObject { /// disabled, each potentially applying to specific session IDs. @Published public internal(set) var incomingVideoQualitySettings: IncomingVideoQualitySettings = .none + /// This property holds the error that indicates the user has been disconnected + /// due to a network-related issue. When the user’s connection is disrupted for longer than the specified + /// timeout, this error will be set with a relevant error type, such as + /// `ClientError.NetworkNotAvailable`. + /// + /// - SeeAlso: ``ClientError.NetworkNotAvailable`` for the type of error set when a + /// disconnection due to network issues occurs. + @Published public internal(set) var disconnectionError: Error? + var sortComparators = defaultComparators private var localCallSettingsUpdate = false diff --git a/Sources/StreamVideo/Controllers/CallController.swift b/Sources/StreamVideo/Controllers/CallController.swift index 19ac5cec8..476f8e4b2 100644 --- a/Sources/StreamVideo/Controllers/CallController.swift +++ b/Sources/StreamVideo/Controllers/CallController.swift @@ -438,6 +438,10 @@ class CallController: @unchecked Sendable { await webRTCCoordinator.setIncomingVideoQualitySettings(value) } + func setDisconnectionTimeout(_ timeout: TimeInterval) { + webRTCCoordinator.setDisconnectionTimeout(timeout) + } + // MARK: - private private func handleParticipantsUpdated() { @@ -551,10 +555,12 @@ class CallController: @unchecked Sendable { call?.update(reconnectionStatus: .connected) case .error: - if let call, let errorStage = stage as? WebRTCCoordinator.StateMachine.Stage.ErrorStage { - call.transitionDueToError(errorStage.error) + Task { @MainActor in + if let call, let errorStage = stage as? WebRTCCoordinator.StateMachine.Stage.ErrorStage { + call.transitionDueToError(errorStage.error) + } + call?.leave() } - call?.leave() default: break } diff --git a/Sources/StreamVideo/Errors/Errors.swift b/Sources/StreamVideo/Errors/Errors.swift index e7c29946b..06f819069 100644 --- a/Sources/StreamVideo/Errors/Errors.swift +++ b/Sources/StreamVideo/Errors/Errors.swift @@ -88,7 +88,10 @@ extension ClientError { /// Networking error. public class NetworkError: ClientError {} - + + /// Represents a network-related error indicating that the network is unavailable. + public class NetworkNotAvailable: ClientError {} + /// Permissions error. public class MissingPermissions: ClientError {} diff --git a/Sources/StreamVideo/WebRTC/v2/StateMachine/Stages/WebRTCCoordinator+Disconnected.swift b/Sources/StreamVideo/WebRTC/v2/StateMachine/Stages/WebRTCCoordinator+Disconnected.swift index 5d0788427..d78eb4726 100644 --- a/Sources/StreamVideo/WebRTC/v2/StateMachine/Stages/WebRTCCoordinator+Disconnected.swift +++ b/Sources/StreamVideo/WebRTC/v2/StateMachine/Stages/WebRTCCoordinator+Disconnected.swift @@ -32,6 +32,7 @@ extension WebRTCCoordinator.StateMachine.Stage { @Injected(\.internetConnectionObserver) private var internetConnectionObserver private var internetObservationCancellable: AnyCancellable? + private var timeInStageCancellable: AnyCancellable? /// Initializes a new instance of `DisconnectedStage`. /// - Parameter context: The context for the disconnected stage. @@ -44,6 +45,7 @@ extension WebRTCCoordinator.StateMachine.Stage { /// Performs cleanup actions before transitioning away from this stage. override func willTransitionAway() { internetObservationCancellable?.cancel() + timeInStageCancellable?.cancel() context.disconnectionSource = nil context.flowError = nil } @@ -108,6 +110,7 @@ extension WebRTCCoordinator.StateMachine.Stage { .statsReporter statsReporter?.sfuAdapter = nil observeInternetConnection() + observeDurationInStage() } } @@ -152,5 +155,49 @@ extension WebRTCCoordinator.StateMachine.Stage { .removeDuplicates() .sink { [weak self] _ in self?.reconnect() } } + + /// Observes the duration spent in the disconnected stage. + /// + /// This method monitors how long the user remains in the disconnected stage of + /// the state machine. It checks if the disconnection timeout is set to a value + /// greater than zero, and if so, it schedules a timer based on this duration. + /// Once the timer expires, indicating the user has been disconnected for too + /// long, a transition is triggered to handle the expired timeout. If the value is equal to zero, we + /// will remain in the disconnected state until the connection restores or the user hangs up. + /// + /// - Important: The timer uses the disconnection timeout to define how long a + /// user can remain in a disconnected state. If the user's connection + /// isn't restored before the timeout expires, the state machine will + /// transition accordingly. + /// + /// - Note: The `context.disconnectionTimeout` holds the value of the timeout + /// duration. If the value is zero or less, no action will be taken, + /// meaning the user can stay disconnected indefinitely without + /// triggering a transition. + private func observeDurationInStage() { + guard context.disconnectionTimeout > 0 else { + return + } + timeInStageCancellable = Foundation + .Timer + .publish(every: context.disconnectionTimeout, on: .main, in: .default) + .autoconnect() + .sink { [weak self] _ in self?.didTimeInStageExpired() } + } + + /// Handles the expiration of the time spent in the disconnected stage. + /// + /// This method is called when the timer, which monitors the user's disconnection + /// timeout, expires. It triggers a state transition to either handle the + /// disconnection as an error or log the issue. Specifically, it transitions to an + /// error state by raising a `ClientError.NetworkNotAvailable` if the user's + /// network is still not available. + /// + /// - Important: This method ensures that users who exceed the allowed time in the + /// disconnected state are transitioned out of the call, preventing + /// them from staying in an unrecoverable state indefinitely. + private func didTimeInStageExpired() { + transitionErrorOrLog(ClientError.NetworkNotAvailable()) + } } } diff --git a/Sources/StreamVideo/WebRTC/v2/StateMachine/Stages/WebRTCCoordinator+Stage.swift b/Sources/StreamVideo/WebRTC/v2/StateMachine/Stages/WebRTCCoordinator+Stage.swift index d06314994..7fc9b65ed 100644 --- a/Sources/StreamVideo/WebRTC/v2/StateMachine/Stages/WebRTCCoordinator+Stage.swift +++ b/Sources/StreamVideo/WebRTC/v2/StateMachine/Stages/WebRTCCoordinator+Stage.swift @@ -16,6 +16,7 @@ extension WebRTCCoordinator.StateMachine { var reconnectAttempts: UInt32 = 0 var currentSFU: String = "" var fastReconnectDeadlineSeconds: TimeInterval = 0 + var disconnectionTimeout: TimeInterval = 0 var reportingIntervalMs: TimeInterval = 0 var reconnectionStrategy: ReconnectionStrategy = .unknown var disconnectionSource: WebSocketConnectionState.DisconnectionSource? = nil diff --git a/Sources/StreamVideo/WebRTC/v2/WebRTCCoordinator.swift b/Sources/StreamVideo/WebRTC/v2/WebRTCCoordinator.swift index 00a80fc0e..794c8d9e7 100644 --- a/Sources/StreamVideo/WebRTC/v2/WebRTCCoordinator.swift +++ b/Sources/StreamVideo/WebRTC/v2/WebRTCCoordinator.swift @@ -377,6 +377,10 @@ final class WebRTCCoordinator: @unchecked Sendable { await stateAdapter.set(incomingVideoQualitySettings: value) } + func setDisconnectionTimeout(_ timeout: TimeInterval) { + stateMachine.currentStage.context.disconnectionTimeout = timeout + } + // MARK: - Private /// Creates the state machine for managing WebRTC stages. diff --git a/StreamVideo.xcodeproj/project.pbxproj b/StreamVideo.xcodeproj/project.pbxproj index 91d1a3f27..e5a7f18f4 100644 --- a/StreamVideo.xcodeproj/project.pbxproj +++ b/StreamVideo.xcodeproj/project.pbxproj @@ -299,6 +299,7 @@ 4093861A2AA09E4A00FF5AF4 /* MemoryLogDestination.swift in Sources */ = {isa = PBXBuildFile; fileRef = 409386192AA09E4A00FF5AF4 /* MemoryLogDestination.swift */; }; 4093861C2AA0A11500FF5AF4 /* LogQueue.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4093861B2AA0A11500FF5AF4 /* LogQueue.swift */; }; 4093861F2AA0A21800FF5AF4 /* MemoryLogViewer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4093861E2AA0A21800FF5AF4 /* MemoryLogViewer.swift */; }; + 409774AE2CC1979F00E0D3EE /* MockCallController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 409774AD2CC1979F00E0D3EE /* MockCallController.swift */; }; 4097B37E2BF4B06A0057992D /* GetCallResponse+Dummy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4097B37D2BF4B06A0057992D /* GetCallResponse+Dummy.swift */; }; 4097B3802BF4B0850057992D /* MockCXProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4097B37F2BF4B0850057992D /* MockCXProvider.swift */; }; 4097B3832BF4E37B0057992D /* OnChangeViewModifier_iOS13.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4097B3822BF4E37B0057992D /* OnChangeViewModifier_iOS13.swift */; }; @@ -1611,6 +1612,7 @@ 409386192AA09E4A00FF5AF4 /* MemoryLogDestination.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemoryLogDestination.swift; sourceTree = ""; }; 4093861B2AA0A11500FF5AF4 /* LogQueue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogQueue.swift; sourceTree = ""; }; 4093861E2AA0A21800FF5AF4 /* MemoryLogViewer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemoryLogViewer.swift; sourceTree = ""; }; + 409774AD2CC1979F00E0D3EE /* MockCallController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockCallController.swift; sourceTree = ""; }; 4097B37D2BF4B06A0057992D /* GetCallResponse+Dummy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GetCallResponse+Dummy.swift"; sourceTree = ""; }; 4097B37F2BF4B0850057992D /* MockCXProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockCXProvider.swift; sourceTree = ""; }; 4097B3822BF4E37B0057992D /* OnChangeViewModifier_iOS13.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnChangeViewModifier_iOS13.swift; sourceTree = ""; }; @@ -4679,6 +4681,7 @@ 8492B87629081CE700006649 /* Mock */ = { isa = PBXGroup; children = ( + 409774AD2CC1979F00E0D3EE /* MockCallController.swift */, 40C75BB62CB4044600C167C3 /* MockThermalStateObserver.swift */, 40FE5EBC2C9C82A6006B0881 /* MockRTCVideoCapturerDelegate.swift */, 40483CB92C9B1E6000B4FCA8 /* MockWebRTCCoordinatorFactory.swift */, @@ -6787,6 +6790,7 @@ 40382F302C88C34600C2D00F /* SFUEventAdapter_Tests.swift in Sources */, 406B3C002C8F5B5300FC93A1 /* MockRTCPeerConnection.swift in Sources */, 400D63F72AC3273F0000BB30 /* ThermalStateObserverTests.swift in Sources */, + 409774AE2CC1979F00E0D3EE /* MockCallController.swift in Sources */, 40AB34BC2C5D30AD00B5B6B3 /* URLSessionConfiguration_WaitsForConnectivityTests.swift in Sources */, 40C4DF502C1C415F0035DBC2 /* LastParticipantAutoLeavePolicyTests.swift in Sources */, 4013387C2BF248E9007318BD /* Mockable.swift in Sources */, diff --git a/StreamVideoTests/Call/Call_Tests.swift b/StreamVideoTests/Call/Call_Tests.swift index f8d2ebb33..eba6d6b36 100644 --- a/StreamVideoTests/Call/Call_Tests.swift +++ b/StreamVideoTests/Call/Call_Tests.swift @@ -3,7 +3,7 @@ // @testable import StreamVideo -import XCTest +@preconcurrency import XCTest @MainActor final class Call_Tests: StreamVideoTestCase { @@ -320,6 +320,22 @@ final class Call_Tests: StreamVideoTestCase { } } + func test_setDisconnectionTimeout_setDisconnectionTimeoutOnCallController() async throws { + let mockCallController = MockCallController() + let call = MockCall(.dummy(callController: mockCallController)) + call.stub(for: \.state, with: .init()) + + call.setDisconnectionTimeout(11) + + XCTAssertEqual( + mockCallController.recordedInputPayload( + TimeInterval.self, + for: .setDisconnectionTimeout + )?.first, + 11 + ) + } + // MARK: - Update State from Coordinator events func test_coordinatorEventReceived_startedRecording_updatesStateCorrectly() async throws { @@ -358,7 +374,6 @@ final class Call_Tests: StreamVideoTestCase { // MARK: - join func test_join_callControllerWasCalledOnlyOnce() async throws { - LogConfig.level = .debug let mockCallController = MockCallController() let call = MockCall(.dummy(callController: mockCallController)) call.stub(for: \.state, with: .init()) @@ -437,61 +452,3 @@ private struct UpdateStateStep { validation = { $0[keyPath: keyPath] == expected } } } - -private final class MockCallController: CallController, Mockable { - typealias FunctionKey = MockFunctionKey - typealias FunctionInputKey = EmptyPayloadable - - enum MockFunctionKey: Hashable, CaseIterable { - case join - } - - var joinError: Error? - var timesJoinWasCalled: Int = 0 - var stubbedProperty: [String: Any] = [:] - var stubbedFunction: [FunctionKey: Any] = [:] - var stubbedFunctionInput: [FunctionKey: [FunctionInputKey]] = [:] - - convenience init() { - self.init( - defaultAPI: .dummy(), - user: .dummy(), - callId: .unique, - callType: .unique, - apiKey: .unique, - videoConfig: .dummy(), - cachedLocation: nil - ) - } - - func stub(for keyPath: KeyPath, with value: T) { - stubbedProperty[propertyKey(for: keyPath)] = value - } - - func stub(for function: FunctionKey, with value: T) { - stubbedFunction[function] = value - } - - override func joinCall( - create: Bool = true, - callSettings: CallSettings?, - options: CreateCallOptions? = nil, - ring: Bool = false, - notify: Bool = false - ) async throws -> JoinCallResponse { - timesJoinWasCalled += 1 - if let stub = stubbedFunction[.join] as? JoinCallResponse { - return stub - } else if let joinError { - throw joinError - } else { - return try await super.joinCall( - create: create, - callSettings: callSettings, - options: options, - ring: ring, - notify: notify - ) - } - } -} diff --git a/StreamVideoTests/Controllers/CallController_Tests.swift b/StreamVideoTests/Controllers/CallController_Tests.swift index d26fe6d18..342b32f22 100644 --- a/StreamVideoTests/Controllers/CallController_Tests.swift +++ b/StreamVideoTests/Controllers/CallController_Tests.swift @@ -452,7 +452,7 @@ final class CallController_Tests: StreamVideoTestCase, @unchecked Sendable { // MARK: - setIncomingVideoQualitySettings - func test_zoom_shouldCallSeetIncomingVideoQualitySettingsOnCoordinator() async throws { + func test_setIncomingVideoQualitySettings_shouldCallSetIncomingVideoQualitySettingsOnCoordinator() async throws { try await prepareAsConnected() let incomingVideoQualitySettings = IncomingVideoQualitySettings.manual( group: .custom(sessionIds: [.unique, .unique]), @@ -474,6 +474,25 @@ final class CallController_Tests: StreamVideoTestCase, @unchecked Sendable { } } + // MARK: - setDisconnectionTimeout + + func test_setDisconnectionTimeout_shouldCallSetDisconnectionTimeoutOnCoordinator() async throws { + try await prepareAsConnected() + + subject.setDisconnectionTimeout(11) + + XCTAssertEqual( + mockWebRTCCoordinatorFactory + .mockCoordinatorStack + .coordinator + .stateMachine + .currentStage + .context + .disconnectionTimeout, + 11 + ) + } + // MARK: - Private helpers private func assertTransitionToStage( diff --git a/StreamVideoTests/Mock/MockCallController.swift b/StreamVideoTests/Mock/MockCallController.swift new file mode 100644 index 000000000..09adc348e --- /dev/null +++ b/StreamVideoTests/Mock/MockCallController.swift @@ -0,0 +1,81 @@ +// +// Copyright © 2024 Stream.io Inc. All rights reserved. +// + +@testable import StreamVideo + +final class MockCallController: CallController, Mockable, @unchecked Sendable { + typealias FunctionKey = MockFunctionKey + + enum MockFunctionKey: Hashable, CaseIterable { + case join + case setDisconnectionTimeout + } + + enum MockFunctionInputKey: Payloadable { + case setDisconnectionTimeout(timeout: TimeInterval) + + var payload: Any { + switch self { + case let .setDisconnectionTimeout(timeout): + return timeout + } + } + } + + var joinError: Error? + var timesJoinWasCalled: Int = 0 + var stubbedProperty: [String: Any] = [:] + var stubbedFunction: [FunctionKey: Any] = [:] + @Atomic var stubbedFunctionInput: [FunctionKey: [MockFunctionInputKey]] = FunctionKey + .allCases + .reduce(into: [FunctionKey: [MockFunctionInputKey]]()) { $0[$1] = [] } + + convenience init() { + self.init( + defaultAPI: .dummy(), + user: .dummy(), + callId: .unique, + callType: .unique, + apiKey: .unique, + videoConfig: .dummy(), + cachedLocation: nil + ) + } + + func stub(for keyPath: KeyPath, with value: T) { + stubbedProperty[propertyKey(for: keyPath)] = value + } + + func stub(for function: FunctionKey, with value: T) { + stubbedFunction[function] = value + } + + override func joinCall( + create: Bool = true, + callSettings: CallSettings?, + options: CreateCallOptions? = nil, + ring: Bool = false, + notify: Bool = false + ) async throws -> JoinCallResponse { + timesJoinWasCalled += 1 + if let stub = stubbedFunction[.join] as? JoinCallResponse { + return stub + } else if let joinError { + throw joinError + } else { + return try await super.joinCall( + create: create, + callSettings: callSettings, + options: options, + ring: ring, + notify: notify + ) + } + } + + override func setDisconnectionTimeout(_ timeout: TimeInterval) { + stubbedFunctionInput[.setDisconnectionTimeout]? + .append(.setDisconnectionTimeout(timeout: timeout)) + } +} diff --git a/StreamVideoTests/WebRTC/v2/StateMachine/Stages/WebRTCCoordinatorStateMachine_DisconnectedStageTests.swift b/StreamVideoTests/WebRTC/v2/StateMachine/Stages/WebRTCCoordinatorStateMachine_DisconnectedStageTests.swift index 99530d9e8..42c31fc82 100644 --- a/StreamVideoTests/WebRTC/v2/StateMachine/Stages/WebRTCCoordinatorStateMachine_DisconnectedStageTests.swift +++ b/StreamVideoTests/WebRTC/v2/StateMachine/Stages/WebRTCCoordinatorStateMachine_DisconnectedStageTests.swift @@ -212,6 +212,15 @@ final class WebRTCCoordinatorStateMachine_DisconnectedStageTests: XCTestCase, @u ) { _ in } } + func test_transition_connectionDoesNotRestoreWithDisconnectionTimeout_landsOnError() async { + subject.context.disconnectionTimeout = 1 + + await assertTransitionAfterTrigger( + expectedTarget: .error, + trigger: {} + ) { _ in } + } + // MARK: - Private helpers private func assertTransitionAfterTrigger( diff --git a/StreamVideoTests/WebRTC/v2/WebRTCCoorindator_Tests.swift b/StreamVideoTests/WebRTC/v2/WebRTCCoorindator_Tests.swift index 47572f520..637404acb 100644 --- a/StreamVideoTests/WebRTC/v2/WebRTCCoorindator_Tests.swift +++ b/StreamVideoTests/WebRTC/v2/WebRTCCoorindator_Tests.swift @@ -516,6 +516,19 @@ final class WebRTCCoordinator_Tests: XCTestCase, @unchecked Sendable { ) } + // MARK: - setDisconnectionTimeout + + func test_setDisconnectionTimeout_correctlyUpdatesStageContext() async throws { + try await prepareAsConnected() + + subject.setDisconnectionTimeout(11) + + XCTAssertEqual( + subject.stateMachine.currentStage.context.disconnectionTimeout, + 11 + ) + } + // MARK: - Private helpers private func assertEqualAsync( diff --git a/docusaurus/docs/iOS/05-ui-cookbook/23-network-disruption b/docusaurus/docs/iOS/05-ui-cookbook/23-network-disruption new file mode 100644 index 000000000..38834fcd8 --- /dev/null +++ b/docusaurus/docs/iOS/05-ui-cookbook/23-network-disruption @@ -0,0 +1,178 @@ +--- +title: Managing network disruptions during a call +--- + +## Summary + +This tutorial guides you through using the `setDisconnectionTimeout` method within the **Call** object to manage user disconnections due to network issues. By setting a timeout, users are given a grace period to reconnect before they are removed from the call, ensuring that temporary network disruptions don’t immediately end their participation. + +## Overview + +The `setDisconnectionTimeout` method allows you to specify how long a user can remain disconnected before being removed from the call. This is particularly useful when users experience brief network interruptions but can reconnect quickly. By setting a timeout, you ensure that users are only dropped if their disconnection persists beyond the specified duration. + +:::note +By default the `disconnectionTimeout` is set to `0`, allowing the user either to remain _in_ the call until their connection restores or select to hang up. +::: + +## Setting Up the Disconnection Timeout + +Once the call has been created, you can set a disconnection timeout that defines how long a user can stay disconnected before being dropped. Here’s how to do it: + +```swift +let call = streamVideo.call(callType: "default", callId: callId) + +// Set the disconnection timeout to 60 seconds +call.setDisconnectionTimeout(60) +``` + +## Inform the user after a disconnection occurs + +With that set, we want to make sure to inform the user once they get disconnected due to a network disruption. To do that, we are going to extend the FeedbackView we created [here](./18-call-quality-rating.mdx). Specifically we are going to update the `DemoFeedbackView` like below: +```swift +struct DemoFeedbackView: View { + + @Environment(\.openURL) private var openURL + @Injected(\.appearance) private var appearance + + @State private var email: String = "" + @State private var comment: String = "" + @State private var rating: Int = 5 + @State private var isSubmitting = false + @State private var toast: Toast? + + private weak var call: Call? + private var dismiss: () -> Void + private var isSubmitEnabled: Bool { !email.isEmpty && !isSubmitting } + + init(_ call: Call, dismiss: @escaping () -> Void) { + self.call = call + self.dismiss = dismiss + } + + var body: some View { + ScrollView { + VStack(spacing: 32) { + Image("feedbackLogo") + + VStack(spacing: 8) { + Text("How is your call going?") + .font(appearance.fonts.headline) + .foregroundColor(appearance.colors.text) + .lineLimit(1) + + Text("All feedback is celebrated!") + .font(appearance.fonts.subheadline) + .foregroundColor(.init(appearance.colors.textLowEmphasis)) + .lineLimit(2) + } + .frame(maxWidth: .infinity, alignment: .center) + .multilineTextAlignment(.center) + + VStack(spacing: 27) { + VStack(spacing: 16) { + TextField( + "Email Address *", + text: $email + ) + .textFieldStyle(DemoTextfieldStyle()) + + DemoTextEditor(text: $comment, placeholder: "Message") + } + + HStack { + Text("Rate Quality") + .font(appearance.fonts.body) + .foregroundColor(.init(appearance.colors.textLowEmphasis)) + .frame(maxWidth: .infinity, alignment: .leading) + + DemoStarRatingView(rating: $rating) + } + } + + HStack { + Button { + resignFirstResponder() + openURL(.init(string: "https://getstream.io/video/#contact")!) + } label: { + Text("Contact Us") + } + .frame(maxWidth: .infinity) + .foregroundColor(appearance.colors.text) + .padding(.vertical, 4) + .clipShape(Capsule()) + .overlay(Capsule().stroke(Color(appearance.colors.textLowEmphasis), lineWidth: 1)) + + Button { + resignFirstResponder() + isSubmitting = true + Task { + do { + try await call?.collectUserFeedback( + rating: rating, + reason: """ + \(email) + \(comment) + """ + ) + Task { @MainActor in + dismiss() + } + isSubmitting = false + } catch { + log.error(error) + dismiss() + isSubmitting = false + } + } + } label: { + if isSubmitting { + ProgressView() + } else { + Text("Submit") + } + } + .frame(maxWidth: .infinity) + .foregroundColor(appearance.colors.text) + .padding(.vertical, 4) + .background(isSubmitEnabled ? appearance.colors.accentBlue : appearance.colors.lightGray) + .disabled(!isSubmitEnabled) + .clipShape(Capsule()) + } + + Spacer() + } + .padding(.horizontal) + } + .toastView(toast: $toast) + .onAppear { checkIfDisconnectionErrorIsAvailable() } + } + + // MARK: - Private helpers + + func checkIfDisconnectionErrorIsAvailable() { + if call?.state.disconnectionError is ClientError.NetworkNotAvailable { + toast = .init( + style: .error, + message: "Your call was ended because it seems your internet connection is down." + ) + } + } +} +``` + +The parts that we changed here are: +> @State private var toast: Toast? +We now defined a state property for the Toast that is going to be presented to the user. + +> `.toastView(toast: $toast)` +We attach the `toastView` ViewModifier on our view (similar to how we are doing with `alert`). + +> `.onAppear { checkIfDisconnectionErrorIsAvailable() }` +On appear we are checking if there is an error of type `NetworkNotAvailable` and if there, we setup a toast to be presented. + +> `checkIfDisconnectionErrorIsAvailable()` +We define a method that will do the error checking for us. + +## Conclusion + +By configuring the `setDisconnectionTimeout` and handling disconnection errors using the `disconnectionError` property, you can provide a more seamless experience for users, allowing them a grace period to reconnect during temporary network issues. Additionally, by integrating user feedback mechanisms, you can give users clear notifications when they have been disconnected due to network problems, helping them understand the issue and take appropriate action. This approach enhances both the reliability of your video calls and user satisfaction, even in less-than-ideal network conditions. \ No newline at end of file From 2ef5dcd2d3580f2cfeb500f6689da10e9cad3af7 Mon Sep 17 00:00:00 2001 From: Ilias Pavlidakis Date: Thu, 17 Oct 2024 23:01:26 +0300 Subject: [PATCH 2/3] Update demo app --- .../Sources/Components/AppEnvironment.swift | 33 +++++++++++++ .../CallingView/DemoCallingViewModifier.swift | 1 + DemoApp/Sources/Views/Login/DebugMenu.swift | 47 +++++++++++++++++++ .../WebRTC/v2/WebRTCCoordinator.swift | 1 + 4 files changed, 82 insertions(+) diff --git a/DemoApp/Sources/Components/AppEnvironment.swift b/DemoApp/Sources/Components/AppEnvironment.swift index 69532c9df..991329fdb 100644 --- a/DemoApp/Sources/Components/AppEnvironment.swift +++ b/DemoApp/Sources/Components/AppEnvironment.swift @@ -442,3 +442,36 @@ extension AppEnvironment { static var autoLeavePolicy: AutoLeavePolicy = .default } + +extension AppEnvironment { + + enum DisconnectionTimeout: Hashable, Debuggable { + case never + case twoMinutes + case custom(TimeInterval) + + var title: String { + switch self { + case .never: + return "Never" + case .twoMinutes: + return "2'" + case let .custom(value): + return "\(value)\"" + } + } + + var duration: TimeInterval { + switch self { + case .never: + return 0 + case .twoMinutes: + return 2 * 60 + case let .custom(value): + return value + } + } + } + + static var disconnectionTimeout: DisconnectionTimeout = .never +} diff --git a/DemoApp/Sources/Views/CallView/CallingView/DemoCallingViewModifier.swift b/DemoApp/Sources/Views/CallView/CallingView/DemoCallingViewModifier.swift index 395d139f7..d2a51aedf 100644 --- a/DemoApp/Sources/Views/CallView/CallingView/DemoCallingViewModifier.swift +++ b/DemoApp/Sources/Views/CallView/CallingView/DemoCallingViewModifier.swift @@ -72,6 +72,7 @@ struct DemoCallingViewModifier: ViewModifier { } .onReceive(appState.$activeCall) { call in viewModel.setActiveCall(call) + call?.setDisconnectionTimeout(AppEnvironment.disconnectionTimeout.duration) } .onReceive(appState.$userState) { userState in if userState == .notLoggedIn { diff --git a/DemoApp/Sources/Views/Login/DebugMenu.swift b/DemoApp/Sources/Views/Login/DebugMenu.swift index bf9232bde..a61a85a91 100644 --- a/DemoApp/Sources/Views/Login/DebugMenu.swift +++ b/DemoApp/Sources/Views/Login/DebugMenu.swift @@ -76,6 +76,10 @@ struct DebugMenu: View { didSet { AppEnvironment.callExpiration = callExpiration } } + @State private var disconnectionTimeout: AppEnvironment.DisconnectionTimeout = AppEnvironment.disconnectionTimeout { + didSet { AppEnvironment.disconnectionTimeout = disconnectionTimeout } + } + @State private var isLogsViewerVisible: Bool = false @State private var presentsCustomEnvironmentSetup: Bool = false @@ -86,6 +90,9 @@ struct DebugMenu: View { @State private var customTokenExpirationValue: Int = 0 @State private var presentsCustomTokenExpiration: Bool = false + @State private var customDisconnectionTimeoutValue: TimeInterval = 0 + @State private var presentsCustomDisconnectionTimeout: Bool = false + @State private var autoLeavePolicy: AppEnvironment.AutoLeavePolicy = AppEnvironment.autoLeavePolicy { didSet { AppEnvironment.autoLeavePolicy = autoLeavePolicy } } @@ -149,6 +156,13 @@ struct DebugMenu: View { label: "Auto Leave policy" ) { self.autoLeavePolicy = $0 } + makeMenu( + for: [.never, .twoMinutes], + currentValue: disconnectionTimeout, + additionalItems: { customDisconnectionTimeoutView }, + label: "Disconnection Timeout" + ) { self.disconnectionTimeout = $0 } + makeMenu( for: [.visible, .hidden], currentValue: performanceTrackerVisibility, @@ -213,6 +227,14 @@ struct DebugMenu: View { transformer: { Int($0) ?? 0 }, action: { self.tokenExpiration = .custom(customTokenExpirationValue) } ) + .alertWithTextField( + title: "Enter disconnection timeout in seconds", + placeholder: "Interval", + presentationBinding: $presentsCustomDisconnectionTimeout, + valueBinding: $customDisconnectionTimeoutValue, + transformer: { TimeInterval($0) ?? 0 }, + action: { self.disconnectionTimeout = .custom(customDisconnectionTimeoutValue) } + ) } @ViewBuilder @@ -290,6 +312,31 @@ struct DebugMenu: View { } } + @ViewBuilder + private var customDisconnectionTimeoutView: some View { + if case let .custom(value) = AppEnvironment.disconnectionTimeout { + Button { + presentsCustomDisconnectionTimeout = true + } label: { + Label { + Text("Custom (\(value)\")") + } icon: { + Image(systemName: "checkmark") + } + } + } else { + Button { + presentsCustomDisconnectionTimeout = true + } label: { + Label { + Text("Custom") + } icon: { + EmptyView() + } + } + } + } + @ViewBuilder private func makeMenu( for items: [Item], diff --git a/Sources/StreamVideo/WebRTC/v2/WebRTCCoordinator.swift b/Sources/StreamVideo/WebRTC/v2/WebRTCCoordinator.swift index 794c8d9e7..924385899 100644 --- a/Sources/StreamVideo/WebRTC/v2/WebRTCCoordinator.swift +++ b/Sources/StreamVideo/WebRTC/v2/WebRTCCoordinator.swift @@ -379,6 +379,7 @@ final class WebRTCCoordinator: @unchecked Sendable { func setDisconnectionTimeout(_ timeout: TimeInterval) { stateMachine.currentStage.context.disconnectionTimeout = timeout + log.debug("Disconnection timeout was set to \(timeout).") } // MARK: - Private From 870bea2247be30ee5ed7cf0f9487bbafa8c68d59 Mon Sep 17 00:00:00 2001 From: Ilias Pavlidakis Date: Thu, 17 Oct 2024 23:39:01 +0300 Subject: [PATCH 3/3] Fix compilation error --- DemoApp/Sources/Components/Feedback/DemoFeedbackView.swift | 1 + .../05-ui-cookbook/23-network-disruption.swift | 1 + docusaurus/docs/iOS/05-ui-cookbook/23-network-disruption | 1 + 3 files changed, 3 insertions(+) diff --git a/DemoApp/Sources/Components/Feedback/DemoFeedbackView.swift b/DemoApp/Sources/Components/Feedback/DemoFeedbackView.swift index cd4d049fb..4a8821753 100644 --- a/DemoApp/Sources/Components/Feedback/DemoFeedbackView.swift +++ b/DemoApp/Sources/Components/Feedback/DemoFeedbackView.swift @@ -128,6 +128,7 @@ struct DemoFeedbackView: View { // MARK: - Private helpers + @MainActor func checkIfDisconnectionErrorIsAvailable() { if call?.state.disconnectionError is ClientError.NetworkNotAvailable { toast = .init( diff --git a/DocumentationTests/DocumentationTests/DocumentationTests/05-ui-cookbook/23-network-disruption.swift b/DocumentationTests/DocumentationTests/DocumentationTests/05-ui-cookbook/23-network-disruption.swift index c24b0d2cf..346f7f619 100644 --- a/DocumentationTests/DocumentationTests/DocumentationTests/05-ui-cookbook/23-network-disruption.swift +++ b/DocumentationTests/DocumentationTests/DocumentationTests/05-ui-cookbook/23-network-disruption.swift @@ -133,6 +133,7 @@ fileprivate func content() { // MARK: - Private helpers + @MainActor func checkIfDisconnectionErrorIsAvailable() { if call?.state.disconnectionError is ClientError.NetworkNotAvailable { toast = .init( diff --git a/docusaurus/docs/iOS/05-ui-cookbook/23-network-disruption b/docusaurus/docs/iOS/05-ui-cookbook/23-network-disruption index 38834fcd8..c0a5b57d7 100644 --- a/docusaurus/docs/iOS/05-ui-cookbook/23-network-disruption +++ b/docusaurus/docs/iOS/05-ui-cookbook/23-network-disruption @@ -149,6 +149,7 @@ struct DemoFeedbackView: View { // MARK: - Private helpers + @MainActor func checkIfDisconnectionErrorIsAvailable() { if call?.state.disconnectionError is ClientError.NetworkNotAvailable { toast = .init(