diff --git a/README.md b/README.md index 96e7ddf8f..425969213 100644 --- a/README.md +++ b/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 diff --git a/Sources/SwiftGRPC/Core/ClientNetworkMonitor.swift b/Sources/SwiftGRPC/Core/ClientNetworkMonitor.swift new file mode 100644 index 000000000..615494084 --- /dev/null +++ b/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.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