Skip to content

Commit 7c487e6

Browse files
committed
Add connection status monitoring capabilities
1 parent 52cd7c3 commit 7c487e6

File tree

9 files changed

+214
-21
lines changed

9 files changed

+214
-21
lines changed

PubNubSwiftChatSDK.xcodeproj/project.pbxproj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
3D0F90D32D48DB4700986686 /* MutedUsersManagerInterface.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D0F90D12D48DB4700986686 /* MutedUsersManagerInterface.swift */; };
1414
3D0F90D52D48DEC700986686 /* MutedUsersManagerInterface+AsyncAwait.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D0F90D42D48DEC700986686 /* MutedUsersManagerInterface+AsyncAwait.swift */; };
1515
3D1CE5232D79DED800617200 /* String+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D1CE5222D79DED800617200 /* String+Helpers.swift */; };
16+
3D1E67D72E3CBC84001454C1 /* ConnectionStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D1E67D62E3CBC84001454C1 /* ConnectionStatus.swift */; };
1617
3D27B8D02D2D9A61003AD459 /* ThreadChannelAsyncIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D27B8CF2D2D9A61003AD459 /* ThreadChannelAsyncIntegrationTests.swift */; };
1718
3D27B8D22D2D9BBB003AD459 /* ThreadChannel+AsyncAwait.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D27B8D12D2D9BBB003AD459 /* ThreadChannel+AsyncAwait.swift */; };
1819
3D27B8D42D2EAEA6003AD459 /* MessageDraftAsyncIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D27B8D32D2EAEA5003AD459 /* MessageDraftAsyncIntegrationTests.swift */; };
@@ -154,6 +155,7 @@
154155
3D0F90D12D48DB4700986686 /* MutedUsersManagerInterface.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MutedUsersManagerInterface.swift; sourceTree = "<group>"; };
155156
3D0F90D42D48DEC700986686 /* MutedUsersManagerInterface+AsyncAwait.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MutedUsersManagerInterface+AsyncAwait.swift"; sourceTree = "<group>"; };
156157
3D1CE5222D79DED800617200 /* String+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Helpers.swift"; sourceTree = "<group>"; };
158+
3D1E67D62E3CBC84001454C1 /* ConnectionStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionStatus.swift; sourceTree = "<group>"; };
157159
3D27B8CF2D2D9A61003AD459 /* ThreadChannelAsyncIntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadChannelAsyncIntegrationTests.swift; sourceTree = "<group>"; };
158160
3D27B8D12D2D9BBB003AD459 /* ThreadChannel+AsyncAwait.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ThreadChannel+AsyncAwait.swift"; sourceTree = "<group>"; };
159161
3D27B8D32D2EAEA5003AD459 /* MessageDraftAsyncIntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageDraftAsyncIntegrationTests.swift; sourceTree = "<group>"; };
@@ -497,6 +499,7 @@
497499
3D2CA2472C621876008D2284 /* MessageActionType.swift */,
498500
3DB2A8B82C80993B00167058 /* ChannelType.swift */,
499501
3DB2A8BE2C809B1F00167058 /* EmitEventMethod.swift */,
502+
3D1E67D62E3CBC84001454C1 /* ConnectionStatus.swift */,
500503
);
501504
path = Models;
502505
sourceTree = "<group>";
@@ -691,6 +694,7 @@
691694
3DB5A3202E30D771001741C8 /* ChannelGroup+AsyncAwait.swift in Sources */,
692695
3D2CA2382C5B9ACA008D2284 /* File.swift in Sources */,
693696
3DB5A3192E2F900B001741C8 /* ChannelGroupImpl.swift in Sources */,
697+
3D1E67D72E3CBC84001454C1 /* ConnectionStatus.swift in Sources */,
694698
3DB73A752C57D073007FE249 /* PubNubChat.Event.swift in Sources */,
695699
3D2CA2362C5B9320008D2284 /* PubNubChat+Transform.swift in Sources */,
696700
3DB73A492C513578007FE249 /* MessageReferencedChannel.swift in Sources */,

Sources/Chat+AsyncAwait.swift

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -749,6 +749,20 @@ public extension Chat {
749749
}
750750
}
751751
}
752+
753+
/// Returns a stream of connection status changes.
754+
///
755+
/// - Returns: An `AsyncStream` of ``ConnectionStatus``
756+
func connectionStatusStream() -> AsyncStream<ConnectionStatus> {
757+
AsyncStream { continuation in
758+
let listener = addConnectionStatusListener { status in
759+
continuation.yield(status)
760+
}
761+
continuation.onTermination = { _ in
762+
listener.close()
763+
}
764+
}
765+
}
752766
}
753767

754768
extension ChatImpl {

Sources/Chat.swift

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -525,5 +525,25 @@ public protocol Chat: AnyObject {
525525
/// - **Failure**: An `Error` describing the failure
526526
func removeChannelGroup(id: String, completion: ((Swift.Result<Void, Error>) -> Void)?)
527527

528+
/// Adds a listener for connection status changes.
529+
///
530+
/// - Parameter listener: A closure that will be called when the connection status changes
531+
/// - Returns: ``AutoCloseable`` interface you can call to stop listening for new statuses and clean up resources when they re no longer needed by invoking the ``AutoCloseable/close()`` method
532+
func addConnectionStatusListener(_ listener: @escaping (ConnectionStatus) -> Void) -> AutoCloseable
533+
534+
/// Reconnects the client to the PubNub system.
535+
///
536+
/// - Parameter completion: The async `Result` of the method call
537+
/// - **Success**: A `Void` indicating a success
538+
/// - **Failure**: An `Error` describing the failure
539+
func reconnectSubscriptions()
540+
541+
/// Disconnects the client from the PubNub system.
542+
///
543+
/// - Parameter completion: The async `Result` of the method call
544+
/// - **Success**: A `Void` indicating a success
545+
/// - **Failure**: An `Error` describing the failure
546+
func disconnectSubscriptions()
547+
528548
// swiftlint:disable:next file_length
529549
}

Sources/ChatImpl.swift

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -813,5 +813,32 @@ extension ChatImpl: Chat {
813813
}
814814
}
815815

816+
public func addConnectionStatusListener(_ listener: @escaping (ConnectionStatus) -> Void) -> AutoCloseable {
817+
AutoCloseableImpl(
818+
chat.addConnectionStatusListener { kmpConnectionStatus in
819+
switch kmpConnectionStatus.category {
820+
case .pnConnectionOnline:
821+
listener(.online)
822+
case .pnConnectionOffline:
823+
listener(.offline)
824+
case .pnConnectionError:
825+
if let exception = kmpConnectionStatus.exception {
826+
listener(.connectionError(error: exception.asError()))
827+
}
828+
default:
829+
break
830+
}
831+
}, owner: self
832+
)
833+
}
834+
835+
public func reconnectSubscriptions() {
836+
chat.reconnectSubscriptions()
837+
}
838+
839+
public func disconnectSubscriptions() {
840+
chat.disconnectSubscriptions()
841+
}
842+
816843
// swiftlint:disable:next file_length
817844
}

Sources/Models/ConnectionStatus.swift

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
//
2+
// ConnectionStatus.swift
3+
//
4+
// Copyright (c) PubNub Inc.
5+
// All rights reserved.
6+
//
7+
// This source code is licensed under the license found in the
8+
// LICENSE file in the root directory of this source tree.
9+
//
10+
11+
import Foundation
12+
13+
/// The connection status of the Chat SDK.
14+
public enum ConnectionStatus: Equatable {
15+
/// The client is connected to the PubNub network.
16+
case online
17+
/// The client is disconnected from the PubNub network.
18+
case offline
19+
/// The client is experiencing an error while connecting to the PubNub network.
20+
case connectionError(error: Error)
21+
22+
/// Returns an error (if any) associated with the connection status.
23+
public var error: Error? {
24+
switch self {
25+
case let .connectionError(error):
26+
return error
27+
default:
28+
return nil
29+
}
30+
}
31+
32+
/// Returns `true` if the connection status is `online`, otherwise `false`.
33+
public var isConnected: Bool {
34+
switch self {
35+
case .online:
36+
return true
37+
default:
38+
return false
39+
}
40+
}
41+
42+
public static func == (lhs: ConnectionStatus, rhs: ConnectionStatus) -> Bool {
43+
switch (lhs, rhs) {
44+
case (.online, .online):
45+
return true
46+
case (.offline, .offline):
47+
return true
48+
case (.connectionError, .connectionError):
49+
return true
50+
default:
51+
return false
52+
}
53+
}
54+
}

Tests/AsyncAwait/ChannelAsyncIntegrationTests.swift

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -102,9 +102,8 @@ class ChannelIntegrationTests: BaseAsyncIntegrationTestCase {
102102
}
103103

104104
func testChannelAsync_WhoIsPresent() async throws {
105-
// Keeping a strong reference to this object for test purposes to simulate that someone is already present on the given channel.
106-
// If this object is not retained, it will be deallocated, resulting in no subscription to the channel,
107-
// which would cause the behavior being tested to fail.
105+
// Keeps a strong reference to the returned AsyncStream to prevent it from being deallocated. If this object is not retained,
106+
// the AsyncStream will be deallocated, which would cause the behavior being tested to fail.
108107
let joinResult = try await channel.join()
109108
debugPrint(joinResult)
110109

@@ -116,9 +115,8 @@ class ChannelIntegrationTests: BaseAsyncIntegrationTestCase {
116115
}
117116

118117
func testChannelAsync_IsPresent() async throws {
119-
// Keeping a strong reference to this object for test purposes to simulate that someone is already present on the given channel.
120-
// If this object is not retained, it will be deallocated, resulting in no subscription to the channel,
121-
// which would cause the behavior being tested to fail.
118+
// Keeps a strong reference to the returned AsyncStream to prevent it from being deallocated. If this object is not retained,
119+
// the AsyncStream will be deallocated, which would cause the behavior being tested to fail.
122120
let joinResult = try await channel.join()
123121
debugPrint(joinResult)
124122

Tests/AsyncAwait/ChatAsyncIntegrationTests.swift

Lines changed: 45 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -101,9 +101,8 @@ class ChatAsyncIntegrationTests: BaseAsyncIntegrationTestCase {
101101
let channelId = randomString()
102102
let channel = try await chat.createChannel(id: channelId, name: channelId)
103103

104-
// Keeping a strong reference to this object for test purposes to simulate that someone is already present on the given channel.
105-
// If this object is not retained, it will be deallocated, resulting in no subscription to the channel,
106-
// which would cause the behavior being tested to fail.
104+
// Keeps a strong reference to the returned AsyncStream to prevent it from being deallocated. If this object is not retained,
105+
// the AsyncStream will be deallocated, which would cause the behavior being tested to fail.
107106
let connectResult = channel.connect()
108107
debugPrint(connectResult)
109108

@@ -122,9 +121,8 @@ class ChatAsyncIntegrationTests: BaseAsyncIntegrationTestCase {
122121
let channelId = randomString()
123122
let channel = try await chat.createChannel(id: channelId, name: channelId)
124123

125-
// Keeping a strong reference to these objects for test purposes to simulate that someone is already present on the given channel.
126-
// If this object is not retained, it will be deallocated, resulting in no subscription to the channel,
127-
// which would cause the behavior being tested to fail.
124+
// Keeps a strong reference to the returned AsyncStream to prevent it from being deallocated. If this object is not retained,
125+
// the AsyncStream will be deallocated, which would cause the behavior being tested to fail.
128126
let connectResult = channel.connect()
129127
let joinResult = try await channel.join()
130128

@@ -229,9 +227,8 @@ class ChatAsyncIntegrationTests: BaseAsyncIntegrationTestCase {
229227
name: "ChannelName"
230228
)
231229

232-
// Keeping a strong reference to these objects for test purposes to simulate that someone is already present on the given channel.
233-
// If this object is not retained, it will be deallocated, resulting in no subscription to the channel,
234-
// which would cause the behavior being tested to fail.
230+
// Keeps a strong reference to the returned AsyncStream to prevent it from being deallocated. If this object is not retained,
231+
// the AsyncStream will be deallocated, which would cause the behavior being tested to fail.
235232
let joinValue = try await channel.join()
236233
debugPrint(joinValue)
237234

@@ -636,5 +633,44 @@ class ChatAsyncIntegrationTests: BaseAsyncIntegrationTestCase {
636633
try await chat.removeChannelGroup(id: channelGroup.id)
637634
}
638635

636+
func testChatAsync_ConnectionStatusListener() async throws {
637+
let expectation = expectation(description: "Status Listener")
638+
expectation.assertForOverFulfill = true
639+
expectation.expectedFulfillmentCount = 2
640+
641+
let channel = try await chat.createChannel(id: randomString())
642+
643+
let statusStreamTask = Task { [weak chat] in
644+
if let chat {
645+
for await status in chat.connectionStatusStream() {
646+
if status == .online {
647+
expectation.fulfill()
648+
chat.disconnectSubscriptions()
649+
} else if status == .offline {
650+
expectation.fulfill()
651+
} else {
652+
XCTFail("Unexpected condition")
653+
}
654+
}
655+
} else {
656+
XCTFail("Unexpected condition")
657+
}
658+
}
659+
660+
let connectTask = Task {
661+
for await message in channel.connect() {
662+
debugPrint(message)
663+
}
664+
}
665+
666+
addTeardownBlock { [unowned self] in
667+
statusStreamTask.cancel()
668+
connectTask.cancel()
669+
_ = try? await chat.deleteChannel(id: channel.id)
670+
}
671+
672+
await fulfillment(of: [expectation], timeout: 4)
673+
}
674+
639675
// swiftlint:disable:next file_length
640676
}

Tests/AsyncAwait/UserAsyncIntegrationTests.swift

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -115,9 +115,8 @@ class UserAsyncIntegrationTests: BaseAsyncIntegrationTestCase {
115115
let channelId = randomString()
116116
let createdChannel = try await chat.createChannel(id: channelId, name: channelId)
117117

118-
// Keeping a strong reference to this object for test purposes to simulate that someone is already present on the given channel.
119-
// If this object is not retained, it will be deallocated, resulting in no subscription to the channel,
120-
// which would cause the behavior being tested to fail.
118+
// Keeps a strong reference to the returned AsyncStream to prevent it from being deallocated. If this object is not retained,
119+
// the AsyncStream will be deallocated, which would cause the behavior being tested to fail.
121120
let connectResult = createdChannel.connect()
122121
debugPrint(connectResult)
123122

@@ -162,9 +161,8 @@ class UserAsyncIntegrationTests: BaseAsyncIntegrationTestCase {
162161
let channelId = randomString()
163162
let channel = try await chat.createChannel(id: channelId, name: channelId)
164163

165-
// Keeping a strong reference to this object for test purposes to simulate that someone is already present on the given channel.
166-
// If this object is not retained, it will be deallocated, resulting in no subscription to the channel,
167-
// which would cause the behavior being tested to fail.
164+
// Keeps a strong reference to the returned AsyncStream to prevent it from being deallocated. If this object is not retained,
165+
// the AsyncStream will be deallocated, which would cause the behavior being tested to fail.
168166
let connectResult = channel.connect()
169167
debugPrint(connectResult)
170168

Tests/ChatIntegrationTests.swift

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1014,5 +1014,47 @@ class ChatIntegrationTests: BaseClosureIntegrationTestCase {
10141014
}
10151015
}
10161016

1017+
func testChat_ConnectionStatusListener() throws {
1018+
let expectation = expectation(description: "Status Listener")
1019+
expectation.assertForOverFulfill = true
1020+
expectation.expectedFulfillmentCount = 2
1021+
1022+
let channel = try awaitResultValue {
1023+
chat.createChannel(
1024+
id: randomString(),
1025+
completion: $0
1026+
)
1027+
}
1028+
1029+
let statusListenerCloseable = chat.addConnectionStatusListener { [unowned self] newStatus in
1030+
if newStatus == .online {
1031+
expectation.fulfill()
1032+
chat.disconnectSubscriptions()
1033+
} else if newStatus == .offline {
1034+
expectation.fulfill()
1035+
} else {
1036+
XCTFail("Unexpected condition")
1037+
}
1038+
}
1039+
1040+
let autoCloseable = channel.connect { _ in }
1041+
1042+
defer {
1043+
statusListenerCloseable.close()
1044+
autoCloseable.close()
1045+
1046+
addTeardownBlock { [unowned self] in
1047+
try awaitResult {
1048+
self.chat.deleteChannel(
1049+
id: channel.id,
1050+
completion: $0
1051+
)
1052+
}
1053+
}
1054+
}
1055+
1056+
wait(for: [expectation], timeout: 4)
1057+
}
1058+
10171059
// swiftlint:disable:next file_length
10181060
}

0 commit comments

Comments
 (0)