mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-11-11 09:37:45 +00:00
android, ios: report a network change when the network identity changes
With the simplexmq change this depends on, the core reconnects to the servers on every online network event, so the event must also be sent when the network type stays the same but it is a different network or an address was removed (another Wi-Fi network, a new IP address after internet loss) - the connections via the previous network are dead, and the OS reports it only after ~15 minutes. Added addresses do not affect existing connections and are not reported, unless there were none before. The event is not sent when the core already has this network info for the network its connections are made via. - android: track the Network handle and the link addresses in NetworkObserver, handle onLinkPropertiesChanged, and do not report online after an offline event that was not sent yet. - android: the observer is serialized, and the removed addresses are counted rather than compared - an address removed and assigned again also leaves the connections made via it dead, so the count is what the next report is deduplicated against. - android: the loss of a network that is no longer the current one is not reported - it is lost after the network that replaced it became the current one, and the connections are made via that one. - ios: compare the local addresses of the interface the path uses and its gateways - NWPath has no network identity and no addresses. - a report that was not accepted is repeated when the next event is received. - what was reported is reset when the chat controller is created anew, so that the network info is reported to it and the reports for the previous one are dropped. - android: the wake lock taken for the report is released when nothing was sent or the network is offline, instead of being held for its full timeout - the events that report nothing are new, as the info the core already has for the network its connections are made via is not sent again.
This commit is contained in:
1 parent
7749ebb66b
commit
b32a8cfb10
2 files changed
+187
-37
No files matched your search
@@ -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<String> = []
|
||||
private var prevAddresses: Set<String> = []
|
||||
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<String>, _ curr: Set<String>) -> 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<String> {
|
||||
var ifap: UnsafeMutablePointer<ifaddrs>?
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+134
-31
@@ -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<InetAddress>? = 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<InetAddress>?, addresses: Set<InetAddress>?): 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<InetAddress> = 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
|
||||
|
||||
Reference in new issue
Block a user