Browse Source

Add ClientNetworkMonitor for tracking network changes (#387)

The SwiftGRPC implementation that is backed by [gRPC-Core(https://github.com/grpc/grpc) (and not SwiftNIO) is known to have some connectivity issues on iOS clients - namely, silently disconnecting (making it seem like active calls/connections are hanging) when switching between wifi <> cellular. The root cause of these problems is that the backing gRPC-Core doesn't get the optimizations made by iOS' networking stack when these types of changes occur, and isn't able to handle them itself.

There is also documentation of this behavior in [this gRPC-Core readme](https://github.com/grpc/grpc/blob/v1.19.0/src/objective-c/NetworkTransitionBehavior.md).

To aid in this problem, we're adding a [`ClientNetworkMonitor`](./Sources/SwiftGRPC/Core/ClientNetworkMonitor.swift) that monitors the device for events that can cause gRPC to disconnect silently. We recommend utilizing this component to call `shutdown()` (or destroy) any active `Channel` instances, and start new ones when the network is reachable.

Details:
- **Switching between wifi <> cellular:** Channels silently disconnect
- **Switching between 3G <> LTE (etc.):** Channels silently disconnect
- **Network becoming unreachable:** Most times channels will time out after a few seconds, but `ClientNetworkMonitor` will notify of these changes much faster
- **Switching between background <> foreground:** No known issues

Original issue: https://github.com/grpc/grpc-swift/issues/337.
Michael Rebello 7 years ago
parent
commit
3b3c5c55bd
2 changed files with 191 additions and 2 deletions
  1. 29 2
      README.md
  2. 162 0
      Sources/SwiftGRPC/Core/ClientNetworkMonitor.swift

+ 29 - 2
README.md

@@ -91,6 +91,7 @@ By convention the `--swift_out` option invokes the `protoc-gen-swift`
 plugin and `--swiftgrpc_out` invokes `protoc-gen-swiftgrpc`.
 
 #### Parameters
+
 To pass extra parameters to the plugin, use a comma-separated parameter list
 separated from the output directory by a colon.
 
@@ -133,6 +134,32 @@ to directly build API clients and servers with no generated code.
 For an example of this in Swift, please see the
 [Simple](Examples/SimpleXcode) example.
 
+### Known issues
+
+The SwiftGRPC implementation that is backed by [gRPC-Core](https://github.com/grpc/grpc)
+(and not SwiftNIO) is known to have some connectivity issues on iOS clients - namely, silently
+disconnecting (making it seem like active calls/connections are hanging) when switching
+between wifi <> cellular or between cellular technologies (3G <> LTE). The root cause of these problems is that the
+backing gRPC-Core doesn't get the optimizations made by iOS' networking stack when these
+types of changes occur, and isn't able to handle them itself.
+
+There is also documentation of this behavior in [this gRPC-Core readme](https://github.com/grpc/grpc/blob/v1.19.0/src/objective-c/NetworkTransitionBehavior.md).
+
+To aid in this problem, there is a [`ClientNetworkMonitor`](./Sources/SwiftGRPC/Core/ClientNetworkMonitor.swift)
+that monitors the device for events that can cause gRPC to disconnect silently. We recommend utilizing this component to
+call `shutdown()` (or destroy) any active `Channel` instances, and start new ones when the network is reachable.
+
+Setting the [`keepAliveTimeout` argument](https://github.com/grpc/grpc-swift/blob/0.7.0/Sources/SwiftGRPC/Core/ChannelArgument.swift#L46)
+on channels is also encouraged.
+
+Details:
+- **Switching between wifi <> cellular:** Channels silently disconnect
+- **Switching between 3G <> LTE (etc.):** Channels silently disconnect
+- **Network becoming unreachable:** Most times channels will time out after a few seconds, but `ClientNetworkMonitor` will notify of these changes much faster
+- **Switching between background <> foreground:** No known issues
+
+Original SwiftGRPC issue: https://github.com/grpc/grpc-swift/issues/337.
+
 ## Having build problems?
 
 grpc-swift depends on Swift, Xcode, and swift-protobuf. We are currently
@@ -175,11 +202,11 @@ When issuing a new release, the following steps should be followed:
 1. Run the CocoaPods linter to ensure that there are no new warnings/errors:
 
     `$ pod spec lint SwiftGRPC.podspec`
-    
+
 1. Update the Carthage Xcode project (diff will need to be checked in with the version bump):
 
     `$ make project-carthage`
-    
+
 1. Bump the version in the `SwiftGRPC.podspec` file
 
 1. Merge these changes, then create a new `Release` with corresponding `Tag`. Be sure to include a list of changes in the message

+ 162 - 0
Sources/SwiftGRPC/Core/ClientNetworkMonitor.swift

@@ -0,0 +1,162 @@
+/*
+ * Copyright 2019, gRPC Authors All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+#if os(iOS)
+import CoreTelephony
+import Dispatch
+import SystemConfiguration
+
+/// This class may be used to monitor changes on the device that can cause gRPC to silently disconnect (making
+/// it seem like active calls/connections are hanging), then manually shut down / restart gRPC channels as
+/// needed. The root cause of these problems is that the backing gRPC-Core doesn't get the optimizations
+/// made by iOS' networking stack when changes occur on the device such as switching from wifi to cellular,
+/// switching between 3G and LTE, enabling/disabling airplane mode, etc.
+/// Read more: https://github.com/grpc/grpc-swift/tree/master/README.md#known-issues
+/// Original issue: https://github.com/grpc/grpc-swift/issues/337
+open class ClientNetworkMonitor {
+  private let queue: DispatchQueue
+  private let callback: (State) -> Void
+  private let reachability: SCNetworkReachability
+
+  /// Instance of network info being used for obtaining cellular technology names.
+  public let cellularInfo = CTTelephonyNetworkInfo()
+  /// Whether the network is currently reachable. Backed by `SCNetworkReachability`.
+  public private(set) var isReachable: Bool?
+  /// Whether the device is currently using wifi (versus cellular).
+  public private(set) var isUsingWifi: Bool?
+  /// Name of the cellular technology being used (e.g., `CTRadioAccessTechnologyLTE`).
+  public private(set) var cellularName: String?
+
+  /// Represents a state of connectivity.
+  public struct State: Equatable {
+    /// The most recent change that was made to the state.
+    public let lastChange: Change
+    /// Whether this state is currently reachable/online.
+    public let isReachable: Bool
+  }
+
+  /// A change in network condition.
+  public enum Change: Equatable {
+    /// Reachability changed (online <> offline).
+    case reachability(isReachable: Bool)
+    /// The device switched from cellular to wifi.
+    case cellularToWifi
+    /// The device switched from wifi to cellular.
+    case wifiToCellular
+    /// The cellular technology changed (e.g., 3G <> LTE).
+    case cellularTechnology(technology: String)
+  }
+
+  /// Designated initializer for the network monitor. Initializer fails if reachability is unavailable.
+  ///
+  /// - Parameter host:     Host to use for monitoring reachability.
+  /// - Parameter queue:    Queue on which to process and update network changes. Will create one if `nil`.
+  ///                       Should always be used when accessing properties of this class.
+  /// - Parameter callback: Closure to call whenever state changes.
+  public init?(host: String = "google.com", queue: DispatchQueue? = nil, callback: @escaping (State) -> Void) {
+    guard let reachability = SCNetworkReachabilityCreateWithName(nil, host) else {
+      return nil
+    }
+
+    self.queue = queue ?? DispatchQueue(label: "SwiftGRPC.ClientNetworkMonitor.queue")
+    self.callback = callback
+    self.reachability = reachability
+    self.startMonitoringReachability(reachability)
+    self.startMonitoringCellular()
+  }
+
+  deinit {
+    SCNetworkReachabilitySetCallback(self.reachability, nil, nil)
+    SCNetworkReachabilityUnscheduleFromRunLoop(self.reachability, CFRunLoopGetMain(),
+                                               CFRunLoopMode.commonModes.rawValue)
+    NotificationCenter.default.removeObserver(self)
+  }
+
+  // MARK: - Cellular
+
+  private func startMonitoringCellular() {
+    let notificationName: Notification.Name
+    if #available(iOS 12.0, *) {
+      notificationName = .CTServiceRadioAccessTechnologyDidChange
+    } else {
+      notificationName = .CTRadioAccessTechnologyDidChange
+    }
+
+    NotificationCenter.default.addObserver(self, selector: #selector(self.cellularDidChange(_:)),
+                                           name: notificationName, object: nil)
+  }
+
+  @objc
+  private func cellularDidChange(_ notification: NSNotification) {
+    self.queue.async {
+      let newCellularName: String?
+      if #available(iOS 12.0, *) {
+        let cellularKey = notification.object as? String
+        newCellularName = cellularKey.flatMap { self.cellularInfo.serviceCurrentRadioAccessTechnology?[$0] }
+      } else {
+        newCellularName = notification.object as? String ?? self.cellularInfo.currentRadioAccessTechnology
+      }
+
+      if let newCellularName = newCellularName, self.cellularName != newCellularName {
+        self.cellularName = newCellularName
+        self.callback(State(lastChange: .cellularTechnology(technology: newCellularName),
+                            isReachable: self.isReachable ?? false))
+      }
+    }
+  }
+
+  // MARK: - Reachability
+
+  private func startMonitoringReachability(_ reachability: SCNetworkReachability) {
+    let info = Unmanaged.passUnretained(self).toOpaque()
+    var context = SCNetworkReachabilityContext(version: 0, info: info, retain: nil,
+                                               release: nil, copyDescription: nil)
+    let callback: SCNetworkReachabilityCallBack = { _, flags, info in
+      let observer = info.map { Unmanaged<ClientNetworkMonitor>.fromOpaque($0).takeUnretainedValue() }
+      observer?.reachabilityDidChange(with: flags)
+    }
+
+    SCNetworkReachabilitySetCallback(reachability, callback, &context)
+    SCNetworkReachabilityScheduleWithRunLoop(reachability, CFRunLoopGetMain(),
+                                             CFRunLoopMode.commonModes.rawValue)
+    self.queue.async { [weak self] in
+      var flags = SCNetworkReachabilityFlags()
+      SCNetworkReachabilityGetFlags(reachability, &flags)
+      self?.reachabilityDidChange(with: flags)
+    }
+  }
+
+  private func reachabilityDidChange(with flags: SCNetworkReachabilityFlags) {
+    self.queue.async {
+      let isUsingWifi = !flags.contains(.isWWAN)
+      let isReachable = flags.contains(.reachable)
+
+      let notifyForWifi = self.isUsingWifi != nil && self.isUsingWifi != isUsingWifi
+      let notifyForReachable = self.isReachable != nil && self.isReachable != isReachable
+
+      self.isUsingWifi = isUsingWifi
+      self.isReachable = isReachable
+
+      if notifyForWifi {
+        self.callback(State(lastChange: isUsingWifi ? .cellularToWifi : .wifiToCellular, isReachable: isReachable))
+      }
+
+      if notifyForReachable {
+        self.callback(State(lastChange: .reachability(isReachable: isReachable), isReachable: isReachable))
+      }
+    }
+  }
+}
+#endif