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:
Narasimha-sc committed 2026-10-05 09:33:06 +00:00
1 parent 7749ebb66b
commit b32a8cfb10
2 files changed
+187 -37

No files matched your search

+53 -6
View File
@@ -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
}
}
}
@@ -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