diff --git a/apps/ios/Shared/Model/NetworkObserver.swift b/apps/ios/Shared/Model/NetworkObserver.swift index 01116fc548..f8f71f4a0f 100644 --- a/apps/ios/Shared/Model/NetworkObserver.swift +++ b/apps/ios/Shared/Model/NetworkObserver.swift @@ -14,12 +14,22 @@ class NetworkObserver { static let shared = NetworkObserver() private let queue: DispatchQueue = DispatchQueue(label: "chat.simplex.app.NetworkObserver") private var prevInfo: UserNetworkInfo? = nil + // The core reconnects to the servers on every online network event, so the event is also sent when + // the network type is the same, but the interface changed or an address or a gateway disappeared - + // the connections via them are dead (added ones, e.g. IPv6 after IPv4, do not affect them). + private var prevInterface: String? = nil + private var prevGateways: Set = [] + private var prevAddresses: Set = [] private var monitor: NWPathMonitor? private let monitorLock: DispatchQueue = DispatchQueue(label: "chat.simplex.app.monitorLock") func restartMonitor() { monitorLock.sync { monitor?.cancel() + // the chat controller is created anew, so the first path update is reported to it; + // it is reset after the update being handled on the monitor queue, so that one that + // reported to the previous controller does not suppress the report to the new one + queue.async { [weak self] in self?.prevInfo = nil } let mon = NWPathMonitor() mon.pathUpdateHandler = { [weak self] path in self?.networkPathChanged(path: path) @@ -34,10 +44,45 @@ class NetworkObserver { networkType: networkTypeFromPath(path), online: path.status == .satisfied ) - if (prevInfo != info) { - prevInfo = info - setNetworkInfo(info) + let gateways = Set(path.gateways.map { "\($0)" }) + // an interface of a type the path uses, not the most preferred available one - Wi-Fi can stay associated + // without internet while the path is satisfied via cellular, and then its addresses are compared + let interface = path.availableInterfaces.first { path.usesInterfaceType($0.type) }?.name + let addresses = localAddresses(interface) + // the interface is compared too - a VPN that reconnected as another one can have the same + // address, and an interface being configured has none yet + let pathChanged = interface != prevInterface || lost(prevGateways, gateways) || lost(prevAddresses, addresses) + // the report that was not accepted is repeated when the next path update is received + if prevInfo != info || (info.online && pathChanged) { + prevInfo = setNetworkInfo(info) ? info : nil } + // the path is recorded when it is not reported too, so that what appeared and then disappeared + // is not compared with the state before it appeared + prevInterface = interface + prevGateways = gateways + prevAddresses = addresses + } + + // one that disappeared means the connections via it are dead, and one that appeared when there were + // none means the network can be used again; added ones (e.g. IPv6 after IPv4) do not affect connections + private func lost(_ prev: Set, _ curr: Set) -> Bool { + !curr.isSuperset(of: prev) || (prev.isEmpty && !curr.isEmpty) + } + + // NWPath has no addresses, and a changed address with the same interface and gateway + // (e.g. the lease was renewed after internet loss) also means the connections via it are dead + private func localAddresses(_ interface: String?) -> Set { + var ifap: UnsafeMutablePointer? + guard let interface = interface, getifaddrs(&ifap) == 0, let first = ifap else { return [] } + defer { freeifaddrs(ifap) } + return Set(sequence(first: first, next: { $0.pointee.ifa_next }).compactMap { ifa -> String? in + guard let sa = ifa.pointee.ifa_addr, + sa.pointee.sa_family == UInt8(AF_INET) || sa.pointee.sa_family == UInt8(AF_INET6), + String(cString: ifa.pointee.ifa_name) == interface else { return nil } + var host = [CChar](repeating: 0, count: Int(NI_MAXHOST)) + guard getnameinfo(sa, socklen_t(sa.pointee.sa_len), &host, socklen_t(host.count), nil, 0, NI_NUMERICHOST) == 0 else { return nil } + return String(cString: host) + }) } private func networkTypeFromPath(_ path: NWPath) -> UserNetworkType { @@ -56,18 +101,20 @@ class NetworkObserver { private static var networkObserver: NetworkObserver? = nil - private func setNetworkInfo(_ info: UserNetworkInfo) { + private func setNetworkInfo(_ info: UserNetworkInfo) -> Bool { logger.debug("setNetworkInfo Network changed: \(String(describing: info))") DispatchQueue.main.sync { ChatModel.shared.networkInfo = info } - self.monitorLock.sync { - guard let ctrl = currentChatCtrl() else { return } + return self.monitorLock.sync { () -> Bool in + guard let ctrl = currentChatCtrl() else { return false } do { // the controller is passed, so that resetting it while the report is sent is not a crash try apiSetNetworkInfo(info, ctrl: ctrl) + return true } catch let err { logger.error("setNetworkInfo error: \(responseError(err))") + return false } } } diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/helpers/NetworkObserver.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/helpers/NetworkObserver.kt index 7e7f888013..9c6589ca0e 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/helpers/NetworkObserver.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/helpers/NetworkObserver.kt @@ -8,23 +8,46 @@ import chat.simplex.common.model.UserNetworkInfo import chat.simplex.common.model.UserNetworkType import chat.simplex.common.platform.* import chat.simplex.common.views.helpers.withBGApi -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay +import kotlinx.coroutines.* +import kotlinx.coroutines.sync.* +import java.net.InetAddress +import java.util.concurrent.atomic.AtomicBoolean class NetworkObserver { private var prevInfo: UserNetworkInfo? = null + // The core reconnects to the servers on every online network event, so the event is also sent + // when the network type is the same, but it is a different network or the addresses changed + // (e.g., another Wi-Fi network or a new IP address) - connections via the previous network are dead. + private var prevNetwork: Network? = null + private var prevAddresses: Set? = null + // the last network info the core accepted and the network it was for (null - it was not accepted, or + // nothing was sent to this chat controller and the core is online with the default network type) + @Volatile private var sentInfo: UserNetworkInfo? = null + @Volatile private var sentNetwork: Network? = null + // incremented when the chat controller is created anew - the reports for the previous one are dropped + @Volatile private var ctrlGeneration = 0 + private val sendMutex = Mutex() + // the numbers of removed addresses of the current network and of those reported to the core: while they + // differ the next online info must be sent even for the same network - the addresses cannot be compared + // instead, as an address removed and assigned again also leaves the connections made via it dead + @Volatile private var addressRemovals = 0 + @Volatile private var sentAddressRemovals = 0 // When having both mobile and Wi-Fi networks enabled with Wi-Fi being active, then disabling Wi-Fi, network reports its offline (which is true) // but since it will be online after switching to mobile, there is no need to inform backend about such temporary change. // But if it will not be online after some seconds, report it and apply required measures private var noNetworkJob = Job() as Job private val networkCallback = object: ConnectivityManager.NetworkCallback() { - override fun onCapabilitiesChanged(network: Network, networkCapabilities: NetworkCapabilities) = networkCapabilitiesChanged(networkCapabilities) - override fun onLost(network: Network) = networkLost() + override fun onCapabilitiesChanged(network: Network, networkCapabilities: NetworkCapabilities) = networkCapabilitiesChanged(network, networkCapabilities) + override fun onLinkPropertiesChanged(network: Network, linkProperties: LinkProperties) = linkPropertiesChanged(network, linkProperties) + override fun onLost(network: Network) = networkLost(network) } private val connectivityManager: ConnectivityManager? = androidAppContext.getSystemService() - fun restartNetworkObserver() { + @Synchronized fun restartNetworkObserver() { + prevInfo = null + sentInfo = null + ctrlGeneration++ if (connectivityManager == null) { Log.e(TAG, "Connectivity manager is unavailable, network observer is disabled") val info = UserNetworkInfo( @@ -32,7 +55,7 @@ class NetworkObserver { online = true, ) prevInfo = info - setNetworkInfo(info) + setNetworkInfo(info, null) return } try { @@ -40,9 +63,10 @@ class NetworkObserver { } catch (e: Exception) { // do nothing } - val initialCapabilities = connectivityManager.getNetworkCapabilities(connectivityManager.activeNetwork) - if (initialCapabilities != null) { - networkCapabilitiesChanged(initialCapabilities) + val activeNetwork = connectivityManager.activeNetwork + val initialCapabilities = connectivityManager.getNetworkCapabilities(activeNetwork) + if (activeNetwork != null && initialCapabilities != null) { + networkCapabilitiesChanged(activeNetwork, initialCapabilities) } else { networkLost() } @@ -53,49 +77,128 @@ class NetworkObserver { } } - private fun networkCapabilitiesChanged(capabilities: NetworkCapabilities) { + @Synchronized private fun networkCapabilitiesChanged(network: Network, capabilities: NetworkCapabilities) { connectivityManager ?: return - val info = UserNetworkInfo( - networkType = networkTypeFromCapabilities(capabilities), - online = capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) && capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED), - ) - if (prevInfo != info) { + val info = networkInfo(capabilities) + if (prevInfo != info || (info.online && prevNetwork != network)) { prevInfo = info - setNetworkInfo(info) + if (info.online) { + prevNetwork = network + // queried directly, rather than cached from onLinkPropertiesChanged, so it is for this network + // regardless of which of the two callbacks fires first + val addresses = connectivityManager.getLinkProperties(network)?.let { addresses(it) } + // onLinkPropertiesChanged will not see a removal this snapshot absorbed, so it is counted here + if (lost(prevAddresses, addresses)) addressRemovals++ + prevAddresses = addresses + } + setNetworkInfo(info, network) } } - private fun networkLost() { + @Synchronized private fun linkPropertiesChanged(network: Network, linkProperties: LinkProperties) { + if (network != prevNetwork) return + val addresses = addresses(linkProperties) + val prev = prevAddresses + prevAddresses = addresses + // the removal is counted even when the previous report failed, so that the next report includes it + if (lost(prev, addresses)) { + Log.d(TAG, "Network changed: addresses") + addressRemovals++ + // prevInfo is null when the previous report failed; the info is read again rather than taken from + // the one the core has, as that can be of another network + val info = prevInfo ?: connectivityManager?.getNetworkCapabilities(network)?.let(::networkInfo) + if (info != null && info.online) setNetworkInfo(info, network) + } + } + + @Synchronized private fun networkLost(network: Network? = null) { + // the network that is replaced by another one is lost after it becomes the current network, + // and the connections are made via it, so its loss is not reported + if (network != null && prevNetwork != null && network != prevNetwork) return Log.d(TAG, "Network changed: lost") val none = UserNetworkInfo(networkType = UserNetworkType.NONE, false) prevInfo = none - setNetworkInfo(none) + prevNetwork = null + prevAddresses = null + setNetworkInfo(none, null) } - private fun setNetworkInfo(info: UserNetworkInfo) { - getWakeLock(timeout = 180000) + private fun setNetworkInfo(info: UserNetworkInfo, network: Network?) { + val releaseWakeLock = getWakeLock(timeout = 180000) Log.d(TAG, "Network changed: $info") noNetworkJob.cancel() - if (info.online) { + val ctrlGen = ctrlGeneration + val sent = AtomicBoolean(false) + val job = if (info.online) { withBGApi { - // the controller is read once and passed, so that replacing it between the two does not fail the command - val ctrl = controller.currentCtrl() - if (ctrl != null && controller.apiSetNetworkInfo(info, ctrl)) { - chatModel.networkInfo.value = info + sendMutex.withLock { + val removals = addressRemovals + // the core has the same network info for the network its connections were made via, e.g. the network + // was reported offline less than 3 seconds ago and it was not sent - the connections are not affected + if ((removals != sentAddressRemovals || sentInfo != info || network != sentNetwork) && + sendNetworkInfo(info, network, removals, ctrlGen)) sent.set(true) } } } else { - noNetworkJob = withBGApi { + withBGApi { delay(3000) - // the controller is read once and passed, so that replacing it between the two does not fail the command - val ctrl = controller.currentCtrl() - if (ctrl != null && controller.apiSetNetworkInfo(info, ctrl)) { - chatModel.networkInfo.value = info + // the report is not cancelled once it started, so that the core is not left with the info + // the app did not record - the online event that cancelled it while it was resuming is + // reported instead of it + val cancelled = !isActive + withContext(NonCancellable) { + sendMutex.withLock { + if (!cancelled && (sentInfo != info || network != sentNetwork)) sendNetworkInfo(info, network, addressRemovals, ctrlGen) + } } - } + }.also { noNetworkJob = it } } + // the job that is cancelled before it starts does not run its body; when the network info is reported + // the wake lock is held until it times out, so that the device does not sleep while the core reconnects + job.invokeOnCompletion { if (!sent.get()) releaseWakeLock() } } + private suspend fun sendNetworkInfo(info: UserNetworkInfo, network: Network?, removals: Int, ctrlGen: Int): Boolean { + if (ctrlGen != ctrlGeneration) return false + // the controller is passed, so that replacing it while the report is sent does not fail the command + val ctrl = controller.currentCtrl() ?: return false.also { reportFailed(ctrlGen) } + // a failed command must not be recorded as sent, and it throws when the response cannot be parsed + val sent = try { controller.apiSetNetworkInfo(info, ctrl) } catch (e: Throwable) { Log.e(TAG, "Error reporting network info: ${e.stackTraceToString()}"); false } + // when it was not sent the network info is reported again when the next event is received + if (sent) setSentInfo(info, network, removals, ctrlGen) else reportFailed(ctrlGen) + return sent + } + + // taken under the lock restartNetworkObserver holds, so that a report for a replaced chat controller + // does not change the state of the one that replaced it + @Synchronized private fun setSentInfo(info: UserNetworkInfo, network: Network?, removals: Int, ctrlGen: Int) { + if (ctrlGen != ctrlGeneration) return + sentInfo = info + sentNetwork = network + sentAddressRemovals = removals + chatModel.networkInfo.value = info + } + + // what the core has is not known any more, so the next event is not deduplicated against it + @Synchronized private fun reportFailed(ctrlGen: Int) { + if (ctrlGen != ctrlGeneration) return + prevInfo = null + sentInfo = null + } + + // one that disappeared means the connections via it are dead, and one that appeared when there were none + // means the network can be used again; added ones (e.g. a rotated IPv6 temporary address) do not affect them + private fun lost(prev: Set?, addresses: Set?): Boolean = + prev != null && (addresses == null || !addresses.containsAll(prev) || (prev.isEmpty() && addresses.isNotEmpty())) + + // LinkAddress equality includes address lifetimes that change with every router advertisement + private fun addresses(linkProperties: LinkProperties): Set = linkProperties.linkAddresses.map { it.address }.toSet() + + private fun networkInfo(capabilities: NetworkCapabilities) = UserNetworkInfo( + networkType = networkTypeFromCapabilities(capabilities), + online = capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) && capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED), + ) + private fun networkTypeFromCapabilities(capabilities: NetworkCapabilities): UserNetworkType = when { capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> UserNetworkType.ETHERNET capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> UserNetworkType.WIFI