From 756f6b938e93aef02b305040bed8b215e59187d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20Andr=C3=A9s=20V=C3=A9lez=20Vidal?= Date: Wed, 28 Oct 2026 16:20:49 +0100 Subject: [PATCH 1/3] feat: notify js on network changes and resend session attributes (MM-71102) --- .../networkclient/ApiClientModuleImpl.kt | 18 ++- .../networkclient/enums/APIClientEvents.kt | 1 + .../SessionAttributesConstants.kt | 8 ++ .../SessionAttributesEngine.kt | 38 +++++- .../SessionAttributesNetworkMonitor.kt | 79 +++++++++++ .../SessionAttributesStore.kt | 48 ++++++- ios/ApiClient/ApiClientEvents.swift | 2 + ios/ApiClient/ApiClientWrapper.swift | 18 ++- .../SessionAttributesCollector.swift | 18 ++- .../SessionAttributesConstants.swift | 11 ++ .../SessionAttributesEngine.swift | 63 ++++++++- .../SessionAttributesStore.swift | 54 +++++++- src/APIClient/NativeApiClient.ts | 1 + .../__tests__/SessionAttributes.test.ts | 40 ++++++ src/SessionAttributes/index.tsx | 30 ++++- .../networkclient/SessionAttributesTest.kt | 127 ++++++++++++++++++ 16 files changed, 535 insertions(+), 21 deletions(-) create mode 100644 android/src/main/java/com/mattermost/networkclient/sessionattributes/SessionAttributesNetworkMonitor.kt create mode 100644 src/SessionAttributes/__tests__/SessionAttributes.test.ts diff --git a/android/src/main/java/com/mattermost/networkclient/ApiClientModuleImpl.kt b/android/src/main/java/com/mattermost/networkclient/ApiClientModuleImpl.kt index 7d93cc423..00dc6bba3 100644 --- a/android/src/main/java/com/mattermost/networkclient/ApiClientModuleImpl.kt +++ b/android/src/main/java/com/mattermost/networkclient/ApiClientModuleImpl.kt @@ -7,12 +7,14 @@ import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.preferencesDataStore +import com.facebook.react.bridge.Arguments import com.facebook.react.bridge.Promise import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.bridge.ReadableMap import com.facebook.react.bridge.WritableMap import com.facebook.react.modules.network.ForwardingCookieHandler import com.facebook.react.modules.network.ReactCookieJarContainer +import com.mattermost.networkclient.enums.ApiClientEvents import com.mattermost.networkclient.helpers.KeyStoreHelper import com.mattermost.networkclient.sessionattributes.SessionAttributesEngine import kotlinx.coroutines.flow.first @@ -34,6 +36,10 @@ class ApiClientModuleImpl(appContext: Context) { const val NAME = "ApiClient" lateinit var context: Context + // Events go through the React context. Native code outside React (e.g. push notification + // handling) creates its own instance with a plain Context, which must not replace it. + @Volatile + private var reactContext: ReactApplicationContext? = null private val clients = mutableMapOf() private val calls = mutableMapOf() private lateinit var sharedPreferences: SharedPreferences @@ -94,15 +100,16 @@ class ApiClientModuleImpl(appContext: Context) { } internal fun sendJSEvent(eventName: String, data: WritableMap?) { - val reactApplicationContext = context as? ReactApplicationContext + val reactApplicationContext = reactContext if (reactApplicationContext?.hasActiveReactInstance() == true) { reactApplicationContext.emitDeviceEvent(eventName, data) } } - private fun setCtx(reactContext: Context) { - context = reactContext + private fun setCtx(appContext: Context) { + context = appContext + (appContext as? ReactApplicationContext)?.let { reactContext = it } } private fun migrateSharedPreferences(reactContext: Context) { @@ -134,6 +141,11 @@ class ApiClientModuleImpl(appContext: Context) { init { setCtx(appContext) SessionAttributesEngine.init(appContext) + SessionAttributesEngine.startNetworkMonitor { serverUrls -> + val data = Arguments.createMap() + data.putArray("serverUrls", Arguments.fromList(serverUrls)) + sendJSEvent(ApiClientEvents.SESSION_ATTRIBUTES_NETWORK_CHANGED.event, data) + } migrateSharedPreferences(appContext) setCookieJar(appContext) } diff --git a/android/src/main/java/com/mattermost/networkclient/enums/APIClientEvents.kt b/android/src/main/java/com/mattermost/networkclient/enums/APIClientEvents.kt index 7c0bc15b5..ba9c6afaa 100644 --- a/android/src/main/java/com/mattermost/networkclient/enums/APIClientEvents.kt +++ b/android/src/main/java/com/mattermost/networkclient/enums/APIClientEvents.kt @@ -4,6 +4,7 @@ enum class ApiClientEvents(val event: String) { DOWNLOAD_PROGRESS("ApiClient-DownloadProgress"), UPLOAD_PROGRESS("ApiClient-UploadProgress"), CLIENT_ERROR("ApiClient-Error"), + SESSION_ATTRIBUTES_NETWORK_CHANGED("ApiClient-SessionAttributesNetworkChanged"), } enum class SslErrors(val event: Int) { diff --git a/android/src/main/java/com/mattermost/networkclient/sessionattributes/SessionAttributesConstants.kt b/android/src/main/java/com/mattermost/networkclient/sessionattributes/SessionAttributesConstants.kt index 3637fd30d..843f137ad 100644 --- a/android/src/main/java/com/mattermost/networkclient/sessionattributes/SessionAttributesConstants.kt +++ b/android/src/main/java/com/mattermost/networkclient/sessionattributes/SessionAttributesConstants.kt @@ -21,4 +21,12 @@ object SessionAttributesConstants { const val SERVER_FQDN = "server_fqdn" const val SSID = "ssid" } + + // Attributes read from the device network, resent as soon as the network changes. + val NETWORK_KEYS = setOf( + AttributeKey.CLIENT_IP_ADDRESS, + AttributeKey.NETWORK_INTERFACE_TYPE, + AttributeKey.SSID, + AttributeKey.VPN_ACTIVE, + ) } diff --git a/android/src/main/java/com/mattermost/networkclient/sessionattributes/SessionAttributesEngine.kt b/android/src/main/java/com/mattermost/networkclient/sessionattributes/SessionAttributesEngine.kt index 93b2c7ca0..88151cf24 100644 --- a/android/src/main/java/com/mattermost/networkclient/sessionattributes/SessionAttributesEngine.kt +++ b/android/src/main/java/com/mattermost/networkclient/sessionattributes/SessionAttributesEngine.kt @@ -12,12 +12,41 @@ object SessionAttributesEngine { private val store: SessionAttributesStore by lazy { SessionAttributesStore(context) } private val collector: SessionAttributesCollector by lazy { SessionAttributesCollector(context, store) } + private var networkMonitor: SessionAttributesNetworkMonitor? = null + @Volatile + private var networkChangeListener: ((List) -> Unit)? = null + fun init(appContext: Context) { if (!::context.isInitialized) { context = appContext.applicationContext } } + /** + * Watches the device network and, when it changes, resets the network attributes + * of every enabled server before calling [listener] with those servers' URLs. + * Calling it again only replaces the listener. + */ + @Synchronized + fun startNetworkMonitor(listener: (List) -> Unit) { + networkChangeListener = listener + if (networkMonitor != null) { + return + } + networkMonitor = SessionAttributesNetworkMonitor( + context, + snapshotProvider = { collector.currentNetworkSnapshot() }, + onChange = ::onNetworkChanged, + ).also { it.start() } + } + + private fun onNetworkChanged() { + val serverUrls = store.resetNetworkFields() + if (serverUrls.isNotEmpty()) { + networkChangeListener?.invoke(serverUrls) + } + } + fun setEnabled(serverUrl: String, enabled: Boolean) { store.setEnabled(serverUrl, enabled) } @@ -80,6 +109,8 @@ object SessionAttributesEngine { return null } + // Read before collecting, so a network change during collection is detected in markSent. + val generation = store.currentNetworkGeneration() val state = store.loadState(serverUrl) ?: return null if (!state.enabled || state.manifest.isEmpty()) { return null @@ -87,6 +118,7 @@ object SessionAttributesEngine { val now = System.currentTimeMillis() val payload = JSONObject() + val sent = mutableListOf() for (field in state.manifest) { val lastSent = state.lastSentAt[field.name] @@ -102,14 +134,14 @@ object SessionAttributesEngine { } payload.put(field.name, value) - state.lastSentAt[field.name] = now + sent.add(field.name) } - if (payload.length() == 0) { + if (sent.isEmpty()) { return null } - store.saveState(serverUrl, state) + store.markSent(serverUrl, sent, now, generation) return Base64.encodeToString(payload.toString().toByteArray(), Base64.NO_WRAP) } diff --git a/android/src/main/java/com/mattermost/networkclient/sessionattributes/SessionAttributesNetworkMonitor.kt b/android/src/main/java/com/mattermost/networkclient/sessionattributes/SessionAttributesNetworkMonitor.kt new file mode 100644 index 000000000..e8be3f928 --- /dev/null +++ b/android/src/main/java/com/mattermost/networkclient/sessionattributes/SessionAttributesNetworkMonitor.kt @@ -0,0 +1,79 @@ +package com.mattermost.networkclient.sessionattributes + +import android.content.Context +import android.net.ConnectivityManager +import android.net.LinkProperties +import android.net.Network +import android.net.NetworkCapabilities +import android.os.Handler +import android.os.HandlerThread +import android.util.Log + +/** + * Tells whether the device network changed between two snapshots. The first + * snapshot is only a baseline, and a snapshot without a network is ignored so a + * drop and reconnect to the same network does not count as a change. + */ +class NetworkChangeDetector { + private var last: NetworkSnapshot? = null + + fun update(snapshot: NetworkSnapshot): Boolean { + if (snapshot.interfaceType.isEmpty()) { + return false + } + val previous = last + last = snapshot + return previous != null && previous != snapshot + } +} + +/** + * Listens to the default network and calls [onChange] once the network attributes + * (interface type, SSID, VPN state or IP address) differ from the last ones seen. + * The OS sends several callbacks for a single switch, so they are debounced and the + * snapshot is taken once the network has settled. + */ +class SessionAttributesNetworkMonitor( + private val context: Context, + private val snapshotProvider: () -> NetworkSnapshot, + private val onChange: () -> Unit, +) { + private val detector = NetworkChangeDetector() + private val thread = HandlerThread("SessionAttributesNetworkMonitor").apply { start() } + private val handler = Handler(thread.looper) + private val evaluate = Runnable { + try { + if (detector.update(snapshotProvider())) { + onChange() + } + } catch (e: Exception) { + Log.w("NetworkClient", "Failed to evaluate network change: ${e.message}") + } + } + + private val callback = object : ConnectivityManager.NetworkCallback() { + override fun onAvailable(network: Network) = schedule() + override fun onLost(network: Network) = schedule() + override fun onCapabilitiesChanged(network: Network, networkCapabilities: NetworkCapabilities) = schedule() + override fun onLinkPropertiesChanged(network: Network, linkProperties: LinkProperties) = schedule() + } + + fun start() { + try { + val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + // The callback only reschedules on [handler], so it can run on the system thread. + connectivityManager.registerDefaultNetworkCallback(callback) + } catch (e: Exception) { + Log.w("NetworkClient", "Failed to watch network changes: ${e.message}") + } + } + + private fun schedule() { + handler.removeCallbacks(evaluate) + handler.postDelayed(evaluate, DEBOUNCE_MS) + } + + companion object { + private const val DEBOUNCE_MS = 1000L + } +} diff --git a/android/src/main/java/com/mattermost/networkclient/sessionattributes/SessionAttributesStore.kt b/android/src/main/java/com/mattermost/networkclient/sessionattributes/SessionAttributesStore.kt index 57ed2bed1..04c47d276 100644 --- a/android/src/main/java/com/mattermost/networkclient/sessionattributes/SessionAttributesStore.kt +++ b/android/src/main/java/com/mattermost/networkclient/sessionattributes/SessionAttributesStore.kt @@ -108,6 +108,8 @@ class SessionAttributesStore(context: Context) { private val cacheLock = Any() private val stateCache = mutableMapOf() private var stableValues: Map? = null + // Bumped on every network change, see markSent. + private var networkGeneration = 0L // Serializes persistence so cache updates reach disk in the order they were made. // Only taken by persist()/delete(), which never re-enter it, so the blocking @@ -125,15 +127,53 @@ class SessionAttributesStore(context: Context) { return synchronized(cacheLock) { state.snapshot() } } - fun saveState(serverUrl: String, state: ServerSessionAttributesState) { + /** + * Records [names] as sent at [sentAt]. A header built before the network changed + * ([generation] is stale) carried the old network values, so those keys stay + * unsent and go out again on the next request. + */ + fun markSent(serverUrl: String, names: Collection, sentAt: Long, generation: Long) { + val state = cachedState(serverUrl) ?: return val json = synchronized(cacheLock) { - val stored = state.snapshot() - stateCache[serverUrl] = stored - stored.toJson().toString() + // The server was disabled or removed while the header was being built. + if (stateCache[serverUrl] !== state || !state.enabled) { + return + } + val staleNetwork = generation != networkGeneration + names.forEach { name -> + if (!staleNetwork || name !in SessionAttributesConstants.NETWORK_KEYS) { + state.lastSentAt[name] = sentAt + } + } + state.toJson().toString() } persist(stateAlias(serverUrl), json) } + fun currentNetworkGeneration(): Long { + synchronized(cacheLock) { + return networkGeneration + } + } + + /** + * Clears the network attributes' sent time on every enabled server, so the next + * request carries the new network values whatever their ttl. Only servers loaded + * in this process are covered, which includes every server the app enabled. + * Returns the URLs of those servers. + */ + fun resetNetworkFields(): List { + val updates = synchronized(cacheLock) { + networkGeneration++ + stateCache.filterValues { it.enabled }.map { (serverUrl, state) -> + SessionAttributesConstants.NETWORK_KEYS.forEach { state.lastSentAt.remove(it) } + serverUrl to state.toJson().toString() + } + } + updates.forEach { (serverUrl, json) -> persist(stateAlias(serverUrl), json) } + return updates.map { it.first } + } + fun removeState(serverUrl: String) { synchronized(cacheLock) { stateCache.remove(serverUrl) diff --git a/ios/ApiClient/ApiClientEvents.swift b/ios/ApiClient/ApiClientEvents.swift index 004d6628d..caf11f353 100644 --- a/ios/ApiClient/ApiClientEvents.swift +++ b/ios/ApiClient/ApiClientEvents.swift @@ -8,6 +8,8 @@ enum ApiEvents: String, CaseIterable { case UPLOAD_PROGRESS = "ApiClient-UploadProgress" case DOWNLOAD_PROGRESS = "ApiClient-DownloadProgress" case CLIENT_ERROR = "ApiClient-Error" + // Matches SessionAttributesConstants.networkChangedNotification. + case SESSION_ATTRIBUTES_NETWORK_CHANGED = "ApiClient-SessionAttributesNetworkChanged" } extension ApiClientWrapper { diff --git a/ios/ApiClient/ApiClientWrapper.swift b/ios/ApiClient/ApiClientWrapper.swift index 0ae79bbe5..e16cbefa9 100644 --- a/ios/ApiClient/ApiClientWrapper.swift +++ b/ios/ApiClient/ApiClientWrapper.swift @@ -12,12 +12,19 @@ import react_native_network_client_session_attributes selector: #selector(self.errorHandler), name: Notification.Name(ApiEvents.CLIENT_ERROR.rawValue), object: nil) + NotificationCenter.default.addObserver(self, + selector: #selector(self.sessionAttributesNetworkChangedHandler), + name: SessionAttributesConstants.networkChangedNotification, + object: nil) } - + deinit { NotificationCenter.default.removeObserver(self, name: Notification.Name(ApiEvents.CLIENT_ERROR.rawValue), object: nil) + NotificationCenter.default.removeObserver(self, + name: SessionAttributesConstants.networkChangedNotification, + object: nil) let sessions = SessionManager.default.getAllSessions() for session in sessions { SessionManager.default.invalidateSession(for: session.key, withReset: false) @@ -468,4 +475,13 @@ import react_native_network_client_session_attributes delegate?.sendEvent(name: ApiEvents.CLIENT_ERROR.rawValue, result: ["serverUrl": serverUrl, "errorCode": errorCode, "errorDescription": errorDescription]) } + + @objc(sessionAttributesNetworkChangedHandler:) + func sessionAttributesNetworkChangedHandler(notification: Notification) { + guard let serverUrls = notification.userInfo?["serverUrls"] as? [String] else { + return + } + delegate?.sendEvent(name: ApiEvents.SESSION_ATTRIBUTES_NETWORK_CHANGED.rawValue, + result: ["serverUrls": serverUrls]) + } } diff --git a/ios/SessionAttributes/SessionAttributesCollector.swift b/ios/SessionAttributes/SessionAttributesCollector.swift index cb286f2f3..5105d3b65 100644 --- a/ios/SessionAttributes/SessionAttributesCollector.swift +++ b/ios/SessionAttributes/SessionAttributesCollector.swift @@ -7,7 +7,7 @@ import Network import NetworkExtension import SystemConfiguration.CaptiveNetwork -struct NetworkSnapshot { +struct NetworkSnapshot: Equatable { let interfaceType: String let ipAddress: String let vpnActive: Bool @@ -26,6 +26,7 @@ public class SessionAttributesCollector: NSObject { private var cachedSsid = "" private var wifiAvailable = false private var locationManager: CLLocationManager? + private var networkUpdateHandler: (() -> Void)? private static let ssidUnavailable = Bundle.main.bundleURL.pathExtension == "appex" && Bundle.main.bundleIdentifier?.contains("NotificationService") ?? false @@ -55,6 +56,7 @@ public class SessionAttributesCollector: NSObject { } else { self.cachedSsid = "" } + self.networkUpdateHandler?() } pathMonitor.start(queue: monitorQueue) @@ -67,6 +69,18 @@ public class SessionAttributesCollector: NSObject { } } + /** + Calls `handler` on every network path or SSID update, and once right away so the + current network can be read as a baseline. The handler runs on the monitor queue, + so it must not call `currentNetworkSnapshot()` synchronously. + */ + func setNetworkUpdateHandler(_ handler: @escaping () -> Void) { + monitorQueue.async { + self.networkUpdateHandler = handler + handler() + } + } + func collect(_ name: String, serverUrl: String) -> String { if let stableValue = store.getStableValue(name), !stableValue.isEmpty { return stableValue @@ -165,6 +179,8 @@ public class SessionAttributesCollector: NSObject { return } self.cachedSsid = ssid + // The SSID arrives after the path update, so report it as its own update. + self.networkUpdateHandler?() } } return diff --git a/ios/SessionAttributes/SessionAttributesConstants.swift b/ios/SessionAttributes/SessionAttributesConstants.swift index 9c8eb38ba..f64d3c77f 100644 --- a/ios/SessionAttributes/SessionAttributesConstants.swift +++ b/ios/SessionAttributes/SessionAttributesConstants.swift @@ -24,4 +24,15 @@ public struct SessionAttributesConstants { public static let serverFqdn = "server_fqdn" public static let ssid = "ssid" } + + // Attributes read from the device network, resent as soon as the network changes. + public static let networkKeys: Set = [ + AttributeKey.clientIpAddress, + AttributeKey.networkInterfaceType, + AttributeKey.ssid, + AttributeKey.vpnActive, + ] + + // Posted with the reset servers' URLs in userInfo["serverUrls"] when the device network changes. + public static let networkChangedNotification = Notification.Name("ApiClient-SessionAttributesNetworkChanged") } diff --git a/ios/SessionAttributes/SessionAttributesEngine.swift b/ios/SessionAttributes/SessionAttributesEngine.swift index ae92670e8..2f863684d 100644 --- a/ios/SessionAttributes/SessionAttributesEngine.swift +++ b/ios/SessionAttributes/SessionAttributesEngine.swift @@ -9,7 +9,23 @@ import Foundation private let store = SessionAttributesStore.shared private let collector = SessionAttributesCollector.shared - private override init() {} + // The OS reports several updates for a single network switch, so the network is + // compared once it has settled. Only touched on networkQueue. + private static let networkChangeDebounce: TimeInterval = 1 + private let networkQueue = DispatchQueue(label: "com.mattermost.networkclient.sessionattributes.networkchange") + private var pendingNetworkEvaluation: DispatchWorkItem? + private var lastNetworkSnapshot: NetworkSnapshot? + + private override init() { + super.init() + // Only the app relays network changes to JS, extensions just attach the header. + guard Bundle.main.bundleURL.pathExtension != "appex" else { + return + } + collector.setNetworkUpdateHandler { [weak self] in + self?.scheduleNetworkEvaluation() + } + } @objc public func setEnabled(_ serverUrl: String, enabled: Bool) { store.setEnabled(enabled, for: serverUrl) @@ -54,6 +70,8 @@ import Foundation } @objc public func getOutboundHeader(_ serverUrl: String) -> String? { + // Read before collecting, so a network change during collection is detected in markSent. + let generation = store.currentNetworkGeneration() guard let state = store.loadState(for: serverUrl), state.enabled, !state.manifest.isEmpty else { return nil } @@ -61,7 +79,6 @@ import Foundation let now = Date().timeIntervalSince1970 * 1000 let lastSentAt = state.lastSentAt var payload: [String: String] = [:] - var updatedLastSentAt = lastSentAt for field in state.manifest { let lastSent = lastSentAt[field.name] @@ -76,14 +93,13 @@ import Foundation } payload[field.name] = value - updatedLastSentAt[field.name] = now } guard !payload.isEmpty else { return nil } - store.updateLastSentAt(updatedLastSentAt, for: serverUrl) + store.markSent(Array(payload.keys), at: now, generation: generation, for: serverUrl) guard let jsonData = try? JSONSerialization.data(withJSONObject: payload, options: []), let jsonString = String(data: jsonData, encoding: .utf8) else { @@ -93,6 +109,45 @@ import Foundation return Data(jsonString.utf8).base64EncodedString() } + private func scheduleNetworkEvaluation() { + networkQueue.async { + self.pendingNetworkEvaluation?.cancel() + let evaluation = DispatchWorkItem { [weak self] in + self?.evaluateNetworkChange() + } + self.pendingNetworkEvaluation = evaluation + self.networkQueue.asyncAfter(deadline: .now() + Self.networkChangeDebounce, execute: evaluation) + } + } + + /** + Compares the network with the last one seen and, when it changed, resets the network + attributes of every enabled server before posting `networkChangedNotification`. The + first reading is only a baseline, and a reading without a network is ignored so a drop + and reconnect to the same network does not count as a change. Runs on networkQueue. + */ + private func evaluateNetworkChange() { + let snapshot = collector.currentNetworkSnapshot() + guard !snapshot.interfaceType.isEmpty else { + return + } + let previous = lastNetworkSnapshot + lastNetworkSnapshot = snapshot + guard let previous, previous != snapshot else { + return + } + + let serverUrls = store.resetNetworkFields() + guard !serverUrls.isEmpty else { + return + } + NotificationCenter.default.post( + name: SessionAttributesConstants.networkChangedNotification, + object: nil, + userInfo: ["serverUrls": serverUrls] + ) + } + private func decodeArray(_ json: String) -> [[String: Any]] { guard let data = json.data(using: .utf8), let array = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] else { diff --git a/ios/SessionAttributes/SessionAttributesStore.swift b/ios/SessionAttributes/SessionAttributesStore.swift index 245473651..801f80d5a 100644 --- a/ios/SessionAttributes/SessionAttributesStore.swift +++ b/ios/SessionAttributes/SessionAttributesStore.swift @@ -22,6 +22,11 @@ public class SessionAttributesStore { private let userDefaults: UserDefaults? private let queue = DispatchQueue(label: "com.mattermost.networkclient.sessionattributes.store") + // States are stored under a hash of the server URL, so the URLs of the servers enabled + // in this process are kept here to reset them on a network change. + private var enabledServers = Set() + // Bumped on every network change, see markSent. + private var networkGeneration = 0 private init() { let appGroupId = Bundle.main.object(forInfoDictionaryKey: "AppGroupIdentifier") as? String @@ -66,6 +71,7 @@ public class SessionAttributesStore { func removeState(for serverUrl: String) { queue.sync { + enabledServers.remove(serverUrl) userDefaults?.removeObject(forKey: stateKey(for: serverUrl)) } } @@ -74,7 +80,10 @@ public class SessionAttributesStore { queue.sync { var state = readState(for: serverUrl) ?? ServerSessionAttributesState(enabled: false, manifest: [], lastSentAt: [:]) state.enabled = enabled - if !enabled { + if enabled { + enabledServers.insert(serverUrl) + } else { + enabledServers.remove(serverUrl) state.manifest = [] state.lastSentAt = [:] } @@ -88,6 +97,7 @@ public class SessionAttributesStore { state.enabled = true state.manifest = manifest state.lastSentAt = [:] + enabledServers.insert(serverUrl) writeState(state, for: serverUrl) } } @@ -118,16 +128,52 @@ public class SessionAttributesStore { } } - func updateLastSentAt(_ lastSentAt: [String: Double], for serverUrl: String) { + /** + Records `names` as sent at `sentAt`. A header built before the network changed + (`generation` is stale) carried the old network values, so those keys stay unsent + and go out again on the next request. + */ + func markSent(_ names: [String], at sentAt: Double, generation: Int, for serverUrl: String) { queue.sync { - guard var state = readState(for: serverUrl) else { + guard var state = readState(for: serverUrl), state.enabled else { return } - state.lastSentAt = lastSentAt + let staleNetwork = generation != networkGeneration + for name in names where !staleNetwork || !SessionAttributesConstants.networkKeys.contains(name) { + state.lastSentAt[name] = sentAt + } writeState(state, for: serverUrl) } } + func currentNetworkGeneration() -> Int { + queue.sync { + networkGeneration + } + } + + /** + Clears the network attributes' sent time on every enabled server, so the next request + carries the new network values whatever their ttl. Returns the URLs of those servers. + */ + func resetNetworkFields() -> [String] { + queue.sync { + networkGeneration += 1 + var serverUrls: [String] = [] + for serverUrl in enabledServers { + guard var state = readState(for: serverUrl), state.enabled else { + continue + } + for key in SessionAttributesConstants.networkKeys { + state.lastSentAt.removeValue(forKey: key) + } + writeState(state, for: serverUrl) + serverUrls.append(serverUrl) + } + return serverUrls + } + } + func setStableValues(_ values: [String: String]) { queue.sync { guard let data = try? JSONEncoder().encode(values) else { diff --git a/src/APIClient/NativeApiClient.ts b/src/APIClient/NativeApiClient.ts index 6f13d6855..82571c688 100644 --- a/src/APIClient/NativeApiClient.ts +++ b/src/APIClient/NativeApiClient.ts @@ -101,6 +101,7 @@ export enum ApiClientEvents { DOWNLOAD_PROGRESS = "ApiClient-DownloadProgress", UPLOAD_PROGRESS = "ApiClient-UploadProgress", CLIENT_ERROR = "ApiClient-Error", + SESSION_ATTRIBUTES_NETWORK_CHANGED = "ApiClient-SessionAttributesNetworkChanged", } export interface Spec extends TurboModule { diff --git a/src/SessionAttributes/__tests__/SessionAttributes.test.ts b/src/SessionAttributes/__tests__/SessionAttributes.test.ts new file mode 100644 index 000000000..effba2e73 --- /dev/null +++ b/src/SessionAttributes/__tests__/SessionAttributes.test.ts @@ -0,0 +1,40 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import { NativeEventEmitter } from "react-native"; + +import { addSessionAttributesNetworkChangeListener } from "../index"; + +jest.mock("../../APIClient/NativeApiClient", () => ({ + __esModule: true, + default: { + addListener: jest.fn(), + removeListeners: jest.fn(), + }, + ApiClientEvents: { + SESSION_ATTRIBUTES_NETWORK_CHANGED: + "ApiClient-SessionAttributesNetworkChanged", + }, +})); + +describe("addSessionAttributesNetworkChangeListener", () => { + it("should pass the reset server URLs to the listener", () => { + const addListener = jest.spyOn( + NativeEventEmitter.prototype, + "addListener", + ); + const listener = jest.fn(); + + addSessionAttributesNetworkChangeListener(listener); + + expect(addListener).toHaveBeenCalledWith( + "ApiClient-SessionAttributesNetworkChanged", + expect.any(Function), + ); + const handler = addListener.mock.calls[0][1]; + handler({ serverUrls: ["https://one.example.com"] }); + + expect(listener).toHaveBeenCalledTimes(1); + expect(listener).toHaveBeenCalledWith(["https://one.example.com"]); + }); +}); diff --git a/src/SessionAttributes/index.tsx b/src/SessionAttributes/index.tsx index 1fb0cff1d..ab3b34d56 100644 --- a/src/SessionAttributes/index.tsx +++ b/src/SessionAttributes/index.tsx @@ -1,7 +1,9 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import NativeApiClient from "../APIClient/NativeApiClient"; +import { type EmitterSubscription, NativeEventEmitter } from "react-native"; + +import NativeApiClient, { ApiClientEvents } from "../APIClient/NativeApiClient"; export type SessionAttributeField = { name: string; @@ -10,6 +12,32 @@ export type SessionAttributeField = { grace_period_seconds?: number; }; +export type SessionAttributesNetworkChangedEvent = { + serverUrls: string[]; +}; + +let emitter: NativeEventEmitter | undefined; + +/** + * Listens for device network changes (interface type, SSID, VPN state or IP address). + * By the time the listener runs, the network attributes of the servers in `serverUrls` + * are reset, so their next request carries the new values whatever their ttl. Only + * servers with session attributes enabled are listed, and no network value is sent. + */ +export const addSessionAttributesNetworkChangeListener = ( + listener: (serverUrls: string[]) => void, +): EmitterSubscription => { + if (!emitter) { + emitter = new NativeEventEmitter(NativeApiClient); + } + return emitter.addListener( + ApiClientEvents.SESSION_ATTRIBUTES_NETWORK_CHANGED, + (event: SessionAttributesNetworkChangedEvent) => { + listener(event.serverUrls); + }, + ); +}; + export const setSessionAttributesEnabled = ( serverUrl: string, enabled: boolean, diff --git a/test-runner/src/test/kotlin/com/mattermost/networkclient/SessionAttributesTest.kt b/test-runner/src/test/kotlin/com/mattermost/networkclient/SessionAttributesTest.kt index 7784702a0..b6cd2276e 100644 --- a/test-runner/src/test/kotlin/com/mattermost/networkclient/SessionAttributesTest.kt +++ b/test-runner/src/test/kotlin/com/mattermost/networkclient/SessionAttributesTest.kt @@ -94,6 +94,62 @@ class SessionAttributesTest { } } + private data class NetworkSnapshot( + val interfaceType: String, + val ipAddress: String, + val vpnActive: Boolean, + val ssid: String, + ) + + /** Matches NetworkChangeDetector in SessionAttributesNetworkMonitor.kt. */ + private class NetworkChangeDetector { + private var last: NetworkSnapshot? = null + + fun update(snapshot: NetworkSnapshot): Boolean { + if (snapshot.interfaceType.isEmpty()) { + return false + } + val previous = last + last = snapshot + return previous != null && previous != snapshot + } + } + + /** + * Matches the network reset in SessionAttributesStore: resetNetworkFields clears the + * network keys' sent time on enabled servers and bumps the generation, and markSent + * skips the network keys of a header built before the last network change. + */ + private class InlineNetworkState { + class State(var enabled: Boolean, val lastSentAt: MutableMap = mutableMapOf()) + + val states = mutableMapOf() + var networkGeneration = 0L + private set + + fun resetNetworkFields(): List { + networkGeneration++ + return states.filterValues { it.enabled }.map { (serverUrl, state) -> + NETWORK_KEYS.forEach { state.lastSentAt.remove(it) } + serverUrl + } + } + + fun markSent(serverUrl: String, names: Collection, sentAt: Long, generation: Long) { + val state = states[serverUrl]?.takeIf { it.enabled } ?: return + val staleNetwork = generation != networkGeneration + names.forEach { name -> + if (!staleNetwork || name !in NETWORK_KEYS) { + state.lastSentAt[name] = sentAt + } + } + } + + companion object { + val NETWORK_KEYS = setOf("client_ip_address", "network_interface_type", "ssid", "vpn_active") + } + } + // --------------------------------------------------------------------------- // Engine TTL logic // --------------------------------------------------------------------------- @@ -303,4 +359,75 @@ class SessionAttributesTest { Assert.assertEquals("state-two", cache.load("https://server.two")) } + + // --------------------------------------------------------------------------- + // Network change detection + // --------------------------------------------------------------------------- + + private val officeWifi = NetworkSnapshot("wifi", "10.0.0.5", vpnActive = false, ssid = "office") + + @Test + fun network_firstSnapshotIsOnlyABaseline() { + Assert.assertFalse(NetworkChangeDetector().update(officeWifi)) + } + + @Test + fun network_sameSnapshotIsNotAChange() { + val detector = NetworkChangeDetector() + detector.update(officeWifi) + + Assert.assertFalse(detector.update(officeWifi.copy())) + } + + @Test + fun network_anyNetworkAttributeChangeIsAChange() { + val changes = listOf( + officeWifi.copy(ssid = "home"), + officeWifi.copy(ipAddress = "10.0.0.6"), + officeWifi.copy(vpnActive = true, interfaceType = "vpn"), + officeWifi.copy(interfaceType = "cellular", ssid = ""), + ) + + changes.forEach { changed -> + val detector = NetworkChangeDetector() + detector.update(officeWifi) + Assert.assertTrue(detector.update(changed)) + } + } + + @Test + fun network_reconnectingToTheSameNetworkIsNotAChange() { + val detector = NetworkChangeDetector() + detector.update(officeWifi) + + Assert.assertFalse(detector.update(NetworkSnapshot("", "", vpnActive = false, ssid = ""))) + Assert.assertFalse(detector.update(officeWifi)) + } + + @Test + fun network_resetClearsOnlyNetworkKeysOfEnabledServers() { + val state = InlineNetworkState() + state.states["https://enabled"] = InlineNetworkState.State( + enabled = true, + lastSentAt = mutableMapOf("ssid" to 1L, "vpn_active" to 1L, "os_version" to 1L), + ) + state.states["https://disabled"] = InlineNetworkState.State(enabled = false) + + val serverUrls = state.resetNetworkFields() + + Assert.assertEquals(listOf("https://enabled"), serverUrls) + Assert.assertEquals(mapOf("os_version" to 1L), state.states["https://enabled"]?.lastSentAt) + } + + @Test + fun network_headerBuiltBeforeAChangeDoesNotMarkNetworkKeysSent() { + val state = InlineNetworkState() + state.states["https://enabled"] = InlineNetworkState.State(enabled = true) + val generation = state.networkGeneration + + state.resetNetworkFields() + state.markSent("https://enabled", listOf("ssid", "os_version"), sentAt = 5L, generation = generation) + + Assert.assertEquals(mapOf("os_version" to 5L), state.states["https://enabled"]?.lastSentAt) + } } From 169f806d1efe07c38b64eaa4abf7a545d2e9d8f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20Andr=C3=A9s=20V=C3=A9lez=20Vidal?= Date: Fri, 30 Oct 2026 18:55:59 +0100 Subject: [PATCH 2/3] fix: only watch the network on android while session attributes are enabled (MM-71102) --- .../networkclient/ApiClientModuleImpl.kt | 2 +- .../SessionAttributesEngine.kt | 40 +++++++++++++------ .../SessionAttributesNetworkMonitor.kt | 12 ++++++ .../SessionAttributesStore.kt | 7 ++++ 4 files changed, 48 insertions(+), 13 deletions(-) diff --git a/android/src/main/java/com/mattermost/networkclient/ApiClientModuleImpl.kt b/android/src/main/java/com/mattermost/networkclient/ApiClientModuleImpl.kt index 00dc6bba3..5044c3b6e 100644 --- a/android/src/main/java/com/mattermost/networkclient/ApiClientModuleImpl.kt +++ b/android/src/main/java/com/mattermost/networkclient/ApiClientModuleImpl.kt @@ -141,7 +141,7 @@ class ApiClientModuleImpl(appContext: Context) { init { setCtx(appContext) SessionAttributesEngine.init(appContext) - SessionAttributesEngine.startNetworkMonitor { serverUrls -> + SessionAttributesEngine.setNetworkChangeListener { serverUrls -> val data = Arguments.createMap() data.putArray("serverUrls", Arguments.fromList(serverUrls)) sendJSEvent(ApiClientEvents.SESSION_ATTRIBUTES_NETWORK_CHANGED.event, data) diff --git a/android/src/main/java/com/mattermost/networkclient/sessionattributes/SessionAttributesEngine.kt b/android/src/main/java/com/mattermost/networkclient/sessionattributes/SessionAttributesEngine.kt index 88151cf24..ac14e7ef2 100644 --- a/android/src/main/java/com/mattermost/networkclient/sessionattributes/SessionAttributesEngine.kt +++ b/android/src/main/java/com/mattermost/networkclient/sessionattributes/SessionAttributesEngine.kt @@ -23,21 +23,32 @@ object SessionAttributesEngine { } /** - * Watches the device network and, when it changes, resets the network attributes - * of every enabled server before calling [listener] with those servers' URLs. - * Calling it again only replaces the listener. + * Sets the function called with the URLs of the servers whose network attributes + * were reset because the device network changed. */ - @Synchronized - fun startNetworkMonitor(listener: (List) -> Unit) { + fun setNetworkChangeListener(listener: (List) -> Unit) { networkChangeListener = listener - if (networkMonitor != null) { - return + } + + /** + * Watches the device network only while a server sends session attributes, so apps + * whose servers do not use them never register a network callback. The app sets the + * manifest again on every connection, which also resets what was sent, so changes + * missed while the monitor was stopped do not matter. + */ + @Synchronized + private fun updateNetworkMonitor() { + val shouldRun = store.hasServersSendingAttributes() + if (shouldRun && networkMonitor == null) { + networkMonitor = SessionAttributesNetworkMonitor( + context, + snapshotProvider = { collector.currentNetworkSnapshot() }, + onChange = ::onNetworkChanged, + ).also { it.start() } + } else if (!shouldRun) { + networkMonitor?.stop() + networkMonitor = null } - networkMonitor = SessionAttributesNetworkMonitor( - context, - snapshotProvider = { collector.currentNetworkSnapshot() }, - onChange = ::onNetworkChanged, - ).also { it.start() } } private fun onNetworkChanged() { @@ -49,10 +60,12 @@ object SessionAttributesEngine { fun setEnabled(serverUrl: String, enabled: Boolean) { store.setEnabled(serverUrl, enabled) + updateNetworkMonitor() } fun removeServer(serverUrl: String) { store.removeState(serverUrl) + updateNetworkMonitor() } fun setManifest(serverUrl: String, manifestJson: String) { @@ -73,6 +86,7 @@ object SessionAttributesEngine { return } store.setManifest(serverUrl, fields) + updateNetworkMonitor() } fun upsertManifestField(serverUrl: String, fieldJson: String) { @@ -83,10 +97,12 @@ object SessionAttributesEngine { return } SAField.fromJson(field)?.let { store.upsertField(serverUrl, it) } + updateNetworkMonitor() } fun removeManifestField(serverUrl: String, name: String) { store.removeField(serverUrl, name) + updateNetworkMonitor() } fun setStableValues(valuesJson: String) { diff --git a/android/src/main/java/com/mattermost/networkclient/sessionattributes/SessionAttributesNetworkMonitor.kt b/android/src/main/java/com/mattermost/networkclient/sessionattributes/SessionAttributesNetworkMonitor.kt index e8be3f928..284769906 100644 --- a/android/src/main/java/com/mattermost/networkclient/sessionattributes/SessionAttributesNetworkMonitor.kt +++ b/android/src/main/java/com/mattermost/networkclient/sessionattributes/SessionAttributesNetworkMonitor.kt @@ -68,6 +68,18 @@ class SessionAttributesNetworkMonitor( } } + /** Stops listening and ends the thread. A stopped monitor cannot be started again. */ + fun stop() { + try { + val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + connectivityManager.unregisterNetworkCallback(callback) + } catch (e: Exception) { + Log.w("NetworkClient", "Failed to stop watching network changes: ${e.message}") + } + handler.removeCallbacksAndMessages(null) + thread.quitSafely() + } + private fun schedule() { handler.removeCallbacks(evaluate) handler.postDelayed(evaluate, DEBOUNCE_MS) diff --git a/android/src/main/java/com/mattermost/networkclient/sessionattributes/SessionAttributesStore.kt b/android/src/main/java/com/mattermost/networkclient/sessionattributes/SessionAttributesStore.kt index 04c47d276..9c86eb88d 100644 --- a/android/src/main/java/com/mattermost/networkclient/sessionattributes/SessionAttributesStore.kt +++ b/android/src/main/java/com/mattermost/networkclient/sessionattributes/SessionAttributesStore.kt @@ -174,6 +174,13 @@ class SessionAttributesStore(context: Context) { return updates.map { it.first } } + /** Whether any server loaded in this process sends attributes (enabled with a manifest). */ + fun hasServersSendingAttributes(): Boolean { + synchronized(cacheLock) { + return stateCache.values.any { it.enabled && it.manifest.isNotEmpty() } + } + } + fun removeState(serverUrl: String) { synchronized(cacheLock) { stateCache.remove(serverUrl) From 7098f78773e68cafd161cb9fcd76eddf6e03b60f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20Andr=C3=A9s=20V=C3=A9lez=20Vidal?= Date: Fri, 30 Oct 2026 19:06:12 +0100 Subject: [PATCH 3/3] fix: take the network baseline when the android monitor starts (MM-71102) --- .../SessionAttributesNetworkMonitor.kt | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/android/src/main/java/com/mattermost/networkclient/sessionattributes/SessionAttributesNetworkMonitor.kt b/android/src/main/java/com/mattermost/networkclient/sessionattributes/SessionAttributesNetworkMonitor.kt index 284769906..591b4b4f6 100644 --- a/android/src/main/java/com/mattermost/networkclient/sessionattributes/SessionAttributesNetworkMonitor.kt +++ b/android/src/main/java/com/mattermost/networkclient/sessionattributes/SessionAttributesNetworkMonitor.kt @@ -51,6 +51,15 @@ class SessionAttributesNetworkMonitor( } } + // Separate from [evaluate], which the first callback would otherwise cancel before it runs. + private val baseline = Runnable { + try { + detector.update(snapshotProvider()) + } catch (e: Exception) { + Log.w("NetworkClient", "Failed to read the starting network: ${e.message}") + } + } + private val callback = object : ConnectivityManager.NetworkCallback() { override fun onAvailable(network: Network) = schedule() override fun onLost(network: Network) = schedule() @@ -59,6 +68,9 @@ class SessionAttributesNetworkMonitor( } fun start() { + // Takes the baseline right away instead of after the first debounce, so a change + // just after starting is still compared against the network attributes were sent on. + handler.post(baseline) try { val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager // The callback only reschedules on [handler], so it can run on the system thread.