diff --git a/android/src/main/java/com/mattermost/networkclient/ApiClientModuleImpl.kt b/android/src/main/java/com/mattermost/networkclient/ApiClientModuleImpl.kt index 7d93cc423..5044c3b6e 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.setNetworkChangeListener { 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..ac14e7ef2 100644 --- a/android/src/main/java/com/mattermost/networkclient/sessionattributes/SessionAttributesEngine.kt +++ b/android/src/main/java/com/mattermost/networkclient/sessionattributes/SessionAttributesEngine.kt @@ -12,18 +12,60 @@ 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 } } + /** + * Sets the function called with the URLs of the servers whose network attributes + * were reset because the device network changed. + */ + fun setNetworkChangeListener(listener: (List) -> Unit) { + networkChangeListener = listener + } + + /** + * 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 + } + } + + private fun onNetworkChanged() { + val serverUrls = store.resetNetworkFields() + if (serverUrls.isNotEmpty()) { + networkChangeListener?.invoke(serverUrls) + } + } + 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) { @@ -44,6 +86,7 @@ object SessionAttributesEngine { return } store.setManifest(serverUrl, fields) + updateNetworkMonitor() } fun upsertManifestField(serverUrl: String, fieldJson: String) { @@ -54,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) { @@ -80,6 +125,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 +134,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 +150,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..591b4b4f6 --- /dev/null +++ b/android/src/main/java/com/mattermost/networkclient/sessionattributes/SessionAttributesNetworkMonitor.kt @@ -0,0 +1,103 @@ +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}") + } + } + + // 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() + override fun onCapabilitiesChanged(network: Network, networkCapabilities: NetworkCapabilities) = schedule() + override fun onLinkPropertiesChanged(network: Network, linkProperties: LinkProperties) = schedule() + } + + 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. + connectivityManager.registerDefaultNetworkCallback(callback) + } catch (e: Exception) { + Log.w("NetworkClient", "Failed to watch network changes: ${e.message}") + } + } + + /** 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) + } + + 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..9c86eb88d 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,60 @@ 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 } + } + + /** 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) 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) + } }