From 7051ea43a3031ba7e13f5bc2d3fb53477a598962 Mon Sep 17 00:00:00 2001 From: Devin Binnie Date: Fri, 17 Jul 2026 12:01:00 -0400 Subject: [PATCH 1/3] feat: [MM-69203] Implement collection, storage and attachment of session attributes --- .../networkclient/ApiClientModuleImpl.kt | 29 +++ .../mattermost/networkclient/NetworkClient.kt | 5 + .../SessionAttributesInterceptor.kt | 34 +++ .../sessionattributes/SessionAttributes.kt | 15 ++ .../SessionAttributesCollector.kt | 140 +++++++++++ .../SessionAttributesConstants.kt | 24 ++ .../SessionAttributesEngine.kt | 111 +++++++++ .../SessionAttributesStore.kt | 218 +++++++++++++++++ .../src/newarch/java/com/ApiClientModule.kt | 49 ++++ .../src/oldarch/java/com/ApiClientModule.kt | 35 +++ ios/Adapters/SessionAttributesAdapter.swift | 27 +++ ios/ApiClient/ApiClient.mm | 64 +++++ ios/ApiClient/ApiClientWrapper.swift | 28 +++ ios/NetworkClient.swift | 7 +- ios/SessionAttributes/SessionAttributes.swift | 13 + .../SessionAttributesCollector.swift | 227 ++++++++++++++++++ .../SessionAttributesConstants.swift | 27 +++ .../SessionAttributesEngine.swift | 120 +++++++++ .../SessionAttributesStore.swift | 149 ++++++++++++ ios/patches/apply_patches.rb | 7 +- package-lock.json | 4 +- package.json | 2 +- react-native-network-client.podspec | 58 +++-- src/APIClient/NativeApiClient.ts | 8 + src/SessionAttributes/index.tsx | 61 +++++ src/index.tsx | 1 + .../networkclient/SessionAttributesTest.kt | 216 +++++++++++++++++ 27 files changed, 1648 insertions(+), 31 deletions(-) create mode 100644 android/src/main/java/com/mattermost/networkclient/interceptors/SessionAttributesInterceptor.kt create mode 100644 android/src/main/java/com/mattermost/networkclient/sessionattributes/SessionAttributes.kt create mode 100644 android/src/main/java/com/mattermost/networkclient/sessionattributes/SessionAttributesCollector.kt create mode 100644 android/src/main/java/com/mattermost/networkclient/sessionattributes/SessionAttributesConstants.kt create mode 100644 android/src/main/java/com/mattermost/networkclient/sessionattributes/SessionAttributesEngine.kt create mode 100644 android/src/main/java/com/mattermost/networkclient/sessionattributes/SessionAttributesStore.kt create mode 100644 ios/Adapters/SessionAttributesAdapter.swift create mode 100644 ios/SessionAttributes/SessionAttributes.swift create mode 100644 ios/SessionAttributes/SessionAttributesCollector.swift create mode 100644 ios/SessionAttributes/SessionAttributesConstants.swift create mode 100644 ios/SessionAttributes/SessionAttributesEngine.swift create mode 100644 ios/SessionAttributes/SessionAttributesStore.swift create mode 100644 src/SessionAttributes/index.tsx create mode 100644 test-runner/src/test/kotlin/com/mattermost/networkclient/SessionAttributesTest.kt diff --git a/android/src/main/java/com/mattermost/networkclient/ApiClientModuleImpl.kt b/android/src/main/java/com/mattermost/networkclient/ApiClientModuleImpl.kt index a3dcb1b96..e0b3940d1 100644 --- a/android/src/main/java/com/mattermost/networkclient/ApiClientModuleImpl.kt +++ b/android/src/main/java/com/mattermost/networkclient/ApiClientModuleImpl.kt @@ -14,6 +14,7 @@ import com.facebook.react.bridge.WritableMap import com.facebook.react.modules.network.ForwardingCookieHandler import com.facebook.react.modules.network.ReactCookieJarContainer import com.mattermost.networkclient.helpers.KeyStoreHelper +import com.mattermost.networkclient.sessionattributes.SessionAttributesEngine import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking import okhttp3.Call @@ -360,6 +361,34 @@ class ApiClientModuleImpl(appContext: Context) { } } + fun setSessionAttributesEnabled(serverUrl: String, enabled: Boolean) { + SessionAttributesEngine.getInstance(context).setEnabled(serverUrl, enabled) + } + + fun removeSessionAttributesServer(serverUrl: String) { + SessionAttributesEngine.getInstance(context).removeServer(serverUrl) + } + + fun setSessionAttributesManifest(serverUrl: String, manifest: String) { + SessionAttributesEngine.getInstance(context).setManifest(serverUrl, manifest) + } + + fun upsertSessionAttributesField(serverUrl: String, field: String) { + SessionAttributesEngine.getInstance(context).upsertManifestField(serverUrl, field) + } + + fun removeSessionAttributesField(serverUrl: String, name: String) { + SessionAttributesEngine.getInstance(context).removeManifestField(serverUrl, name) + } + + fun setSessionAttributesStableValues(values: String) { + SessionAttributesEngine.getInstance(context).setStableValues(values) + } + + fun getSessionAttributesHeader(serverUrl: String): String? { + return SessionAttributesEngine.getInstance(context).getOutboundHeader(serverUrl) + } + // Methods to use with native implementations fun hasClientFor(url: HttpUrl): Boolean { return clients.containsKey(url) diff --git a/android/src/main/java/com/mattermost/networkclient/NetworkClient.kt b/android/src/main/java/com/mattermost/networkclient/NetworkClient.kt index 3a0d275fd..294d7cc67 100644 --- a/android/src/main/java/com/mattermost/networkclient/NetworkClient.kt +++ b/android/src/main/java/com/mattermost/networkclient/NetworkClient.kt @@ -161,6 +161,11 @@ internal class NetworkClient(private val context: Context, private val baseUrl: builder.addInterceptor(bearerTokenInterceptor) } + // Runs after BearerTokenInterceptor so the Authorization header is present when the + // guard is evaluated. Also covers adaptRCTRequest() since RCT requests are executed + // through this same client's okHttpClient. + builder.addInterceptor(SessionAttributesInterceptor(context, baseUrlString)) + applyClientSslConfiguration(options) configureSsl() diff --git a/android/src/main/java/com/mattermost/networkclient/interceptors/SessionAttributesInterceptor.kt b/android/src/main/java/com/mattermost/networkclient/interceptors/SessionAttributesInterceptor.kt new file mode 100644 index 000000000..7a0992fed --- /dev/null +++ b/android/src/main/java/com/mattermost/networkclient/interceptors/SessionAttributesInterceptor.kt @@ -0,0 +1,34 @@ +package com.mattermost.networkclient.interceptors + +import android.content.Context +import com.mattermost.networkclient.sessionattributes.SessionAttributes +import com.mattermost.networkclient.sessionattributes.SessionAttributesConstants +import okhttp3.Interceptor +import okhttp3.Response +import java.io.IOException + +class SessionAttributesInterceptor( + private val context: Context, + private val serverUrl: String, +) : Interceptor { + @Throws(IOException::class) + override fun intercept(chain: Interceptor.Chain): Response { + val request = chain.request() + + val hasAuthorization = request.header("Authorization") != null + val hasSessionAttributes = request.header(SessionAttributesConstants.HEADER_NAME) != null + + if (!hasAuthorization || hasSessionAttributes) { + return chain.proceed(request) + } + + val header = SessionAttributes.getOutboundHeader(context, serverUrl) + ?: return chain.proceed(request) + + val newRequest = request.newBuilder() + .header(SessionAttributesConstants.HEADER_NAME, header) + .build() + + return chain.proceed(newRequest) + } +} diff --git a/android/src/main/java/com/mattermost/networkclient/sessionattributes/SessionAttributes.kt b/android/src/main/java/com/mattermost/networkclient/sessionattributes/SessionAttributes.kt new file mode 100644 index 000000000..44977c2b7 --- /dev/null +++ b/android/src/main/java/com/mattermost/networkclient/sessionattributes/SessionAttributes.kt @@ -0,0 +1,15 @@ +package com.mattermost.networkclient.sessionattributes + +import android.content.Context + +/** + * React-free entry point for native code outside this library (e.g. app + * background handlers, standalone OkHttp usage) to resolve the outbound + * X-MM-Session-Attributes header for a server. + */ +object SessionAttributes { + @JvmStatic + fun getOutboundHeader(context: Context, serverUrl: String): String? { + return SessionAttributesEngine.getInstance(context).getOutboundHeader(serverUrl) + } +} diff --git a/android/src/main/java/com/mattermost/networkclient/sessionattributes/SessionAttributesCollector.kt b/android/src/main/java/com/mattermost/networkclient/sessionattributes/SessionAttributesCollector.kt new file mode 100644 index 000000000..b1f307736 --- /dev/null +++ b/android/src/main/java/com/mattermost/networkclient/sessionattributes/SessionAttributesCollector.kt @@ -0,0 +1,140 @@ +package com.mattermost.networkclient.sessionattributes + +import android.app.admin.DevicePolicyManager +import android.content.Context +import android.content.RestrictionsManager +import android.net.ConnectivityManager +import android.net.NetworkCapabilities +import android.net.Uri +import android.net.wifi.WifiManager +import android.os.Build +import java.net.Inet4Address +import java.net.NetworkInterface +import java.util.concurrent.ConcurrentHashMap + +data class NetworkSnapshot( + val interfaceType: String, + val ipAddress: String, + val vpnActive: Boolean, + val ssid: String, +) + +class SessionAttributesCollector( + private val context: Context, + private val store: SessionAttributesStore, +) { + private val fqdnCache = ConcurrentHashMap() + + fun collect(name: String, serverUrl: String): String { + store.getStableValue(name)?.let { return it } + + val snapshot = currentNetworkSnapshot() + return when (name) { + SessionAttributesConstants.AttributeKey.CLIENT_IP_ADDRESS -> snapshot.ipAddress + SessionAttributesConstants.AttributeKey.NETWORK_INTERFACE_TYPE -> snapshot.interfaceType + SessionAttributesConstants.AttributeKey.VPN_ACTIVE -> if (snapshot.vpnActive) "true" else "false" + SessionAttributesConstants.AttributeKey.SSID -> snapshot.ssid + SessionAttributesConstants.AttributeKey.MDM_ENROLLED -> if (isMdmEnrolled()) "true" else "false" + SessionAttributesConstants.AttributeKey.SERVER_FQDN -> getServerFqdn(serverUrl) + else -> "" + } + } + + private fun getServerFqdn(serverUrl: String): String { + return fqdnCache.computeIfAbsent(serverUrl) { url -> + try { + Uri.parse(url).host ?: "" + } catch (_: Exception) { + "" + } + } + } + + private fun isMdmEnrolled(): Boolean { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + val devicePolicyManager = context.getSystemService(Context.DEVICE_POLICY_SERVICE) as? DevicePolicyManager + if (devicePolicyManager?.isOrganizationOwnedDeviceWithManagedProfile == true) { + return true + } + } + + val restrictionsManager = context.getSystemService(Context.RESTRICTIONS_SERVICE) as? RestrictionsManager + val restrictions = restrictionsManager?.applicationRestrictions + if (restrictions != null) { + if (isTruthyManagedFlag(restrictions.get(SessionAttributesConstants.IS_DEVICE_MANAGED_KEY)) || + isTruthyManagedFlag(restrictions.get(SessionAttributesConstants.IS_SUPERVISED_KEY)) + ) { + return true + } + } + return false + } + + private fun isTruthyManagedFlag(value: Any?): Boolean { + return when (value) { + is Boolean -> value + is Int -> value != 0 + is String -> { + val normalized = value.trim().lowercase() + normalized == "true" || normalized == "1" || normalized == "yes" + } + else -> false + } + } + + fun currentNetworkSnapshot(): NetworkSnapshot { + val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + val activeNetwork = connectivityManager.activeNetwork + val capabilities = activeNetwork?.let { connectivityManager.getNetworkCapabilities(it) } + + val vpnActive = capabilities?.hasTransport(NetworkCapabilities.TRANSPORT_VPN) == true + val interfaceType = when { + vpnActive -> "vpn" + capabilities?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true -> "wifi" + capabilities?.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) == true -> "cellular" + capabilities?.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) == true -> "ethernet" + activeNetwork == null -> "" + else -> "other" + } + + val ipAddress = resolveIpAddress() + val ssid = if (interfaceType == "wifi") resolveSsid() else "" + + return NetworkSnapshot(interfaceType, ipAddress, vpnActive, ssid) + } + + private fun resolveIpAddress(): String { + return try { + val interfaces = NetworkInterface.getNetworkInterfaces() + while (interfaces.hasMoreElements()) { + val networkInterface = interfaces.nextElement() + val addresses = networkInterface.inetAddresses + while (addresses.hasMoreElements()) { + val address = addresses.nextElement() + if (!address.isLoopbackAddress && address is Inet4Address) { + return address.hostAddress ?: "" + } + } + } + "" + } catch (_: Exception) { + "" + } + } + + @Suppress("DEPRECATION") + private fun resolveSsid(): String { + return try { + val wifiManager = context.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager + val info = wifiManager.connectionInfo + val ssid = info?.ssid?.replace("\"", "") ?: "" + if (ssid == "" || ssid == "0x" || ssid == "Wi-Fi" || ssid == "WLAN") { + "" + } else { + ssid + } + } catch (_: Exception) { + "" + } + } +} diff --git a/android/src/main/java/com/mattermost/networkclient/sessionattributes/SessionAttributesConstants.kt b/android/src/main/java/com/mattermost/networkclient/sessionattributes/SessionAttributesConstants.kt new file mode 100644 index 000000000..3637fd30d --- /dev/null +++ b/android/src/main/java/com/mattermost/networkclient/sessionattributes/SessionAttributesConstants.kt @@ -0,0 +1,24 @@ +package com.mattermost.networkclient.sessionattributes + +object SessionAttributesConstants { + const val HEADER_NAME = "X-MM-Session-Attributes" + const val STORE_PREFIX = "sa_" + const val STATE_ALIAS_SUFFIX = "SA_STATE" + const val STABLE_VALUES_ALIAS = "SA_STABLE_VALUES" + const val IS_DEVICE_MANAGED_KEY = "isDeviceManaged" + const val IS_SUPERVISED_KEY = "isSupervised" + + object AttributeKey { + const val VPN_ACTIVE = "vpn_active" + const val CLIENT_DEVICE_ID = "client_device_id" + const val CLIENT_IP_ADDRESS = "client_ip_address" + const val CLIENT_VERSION = "client_version" + const val JAILBREAK_DETECTED = "jailbreak_detected" + const val MDM_ENROLLED = "mdm_enrolled" + const val NETWORK_INTERFACE_TYPE = "network_interface_type" + const val OS_PLATFORM = "os_platform" + const val OS_VERSION = "os_version" + const val SERVER_FQDN = "server_fqdn" + const val SSID = "ssid" + } +} diff --git a/android/src/main/java/com/mattermost/networkclient/sessionattributes/SessionAttributesEngine.kt b/android/src/main/java/com/mattermost/networkclient/sessionattributes/SessionAttributesEngine.kt new file mode 100644 index 000000000..ee9262fff --- /dev/null +++ b/android/src/main/java/com/mattermost/networkclient/sessionattributes/SessionAttributesEngine.kt @@ -0,0 +1,111 @@ +package com.mattermost.networkclient.sessionattributes + +import android.content.Context +import android.util.Base64 +import org.json.JSONArray +import org.json.JSONObject + +class SessionAttributesEngine private constructor(context: Context) { + private val appContext = context.applicationContext + private val store = SessionAttributesStore(appContext) + private val collector = SessionAttributesCollector(appContext, store) + + fun setEnabled(serverUrl: String, enabled: Boolean) { + store.setEnabled(serverUrl, enabled) + } + + fun removeServer(serverUrl: String) { + store.removeState(serverUrl) + } + + fun setManifest(serverUrl: String, manifestJson: String) { + val manifest = try { + JSONArray(manifestJson) + } catch (_: Exception) { + null + } + val fields = mutableListOf() + if (manifest != null) { + for (i in 0 until manifest.length()) { + SAField.fromJson(manifest.getJSONObject(i))?.let { fields.add(it) } + } + } + if (fields.isEmpty()) { + removeServer(serverUrl) + return + } + store.setManifest(serverUrl, fields) + } + + fun upsertManifestField(serverUrl: String, fieldJson: String) { + val field = try { + JSONObject(fieldJson) + } catch (_: Exception) { + return + } + SAField.fromJson(field)?.let { store.upsertField(serverUrl, it) } + } + + fun removeManifestField(serverUrl: String, name: String) { + store.removeField(serverUrl, name) + } + + fun setStableValues(valuesJson: String) { + val json = try { + JSONObject(valuesJson) + } catch (_: Exception) { + return + } + val values = mutableMapOf() + json.keys().forEach { key -> + values[key] = json.optString(key, "") + } + store.setStableValues(values) + } + + fun getOutboundHeader(serverUrl: String): String? { + val state = store.loadState(serverUrl) ?: return null + if (!state.enabled || state.manifest.isEmpty()) { + return null + } + + val now = System.currentTimeMillis() + val payload = JSONObject() + + for (field in state.manifest) { + val lastSent = state.lastSentAt[field.name] + val shouldSend = lastSent == null || field.ttlSeconds == 0 || + (now - lastSent) >= field.ttlSeconds * 1000L + if (!shouldSend) { + continue + } + + val value = collector.collect(field.name, serverUrl) + if (value.isEmpty()) { + continue + } + + payload.put(field.name, value) + state.lastSentAt[field.name] = now + } + + if (payload.length() == 0) { + return null + } + + store.saveState(serverUrl, state) + + return Base64.encodeToString(payload.toString().toByteArray(), Base64.NO_WRAP) + } + + companion object { + @Volatile + private var instance: SessionAttributesEngine? = null + + fun getInstance(context: Context): SessionAttributesEngine { + return instance ?: synchronized(this) { + instance ?: SessionAttributesEngine(context).also { instance = it } + } + } + } +} diff --git a/android/src/main/java/com/mattermost/networkclient/sessionattributes/SessionAttributesStore.kt b/android/src/main/java/com/mattermost/networkclient/sessionattributes/SessionAttributesStore.kt new file mode 100644 index 000000000..a80054dd3 --- /dev/null +++ b/android/src/main/java/com/mattermost/networkclient/sessionattributes/SessionAttributesStore.kt @@ -0,0 +1,218 @@ +package com.mattermost.networkclient.sessionattributes + +import android.content.Context +import androidx.datastore.core.DataStore +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.mattermost.networkclient.helpers.KeyStoreHelper +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import org.json.JSONArray +import org.json.JSONObject +import java.security.MessageDigest + +data class SAField( + val name: String, + val type: String, + val ttlSeconds: Int, + val gracePeriodSeconds: Int, +) { + fun toJson(): JSONObject { + return JSONObject().apply { + put("name", name) + put("type", type) + put("ttl_seconds", ttlSeconds) + put("grace_period_seconds", gracePeriodSeconds) + } + } + + companion object { + fun fromJson(json: JSONObject): SAField? { + val name = json.optString("name", "") + val type = json.optString("type", "") + if (name.isEmpty() || type.isEmpty()) { + return null + } + return SAField( + name = name, + type = type, + ttlSeconds = json.optInt("ttl_seconds", 0), + gracePeriodSeconds = json.optInt("grace_period_seconds", 0), + ) + } + } +} + +data class ServerSessionAttributesState( + var enabled: Boolean, + var manifest: MutableList, + var lastSentAt: MutableMap, +) { + fun toJson(): JSONObject { + val manifestArray = JSONArray() + manifest.forEach { manifestArray.put(it.toJson()) } + val lastSent = JSONObject() + lastSentAt.forEach { (key, value) -> lastSent.put(key, value) } + return JSONObject().apply { + put("enabled", enabled) + put("manifest", manifestArray) + put("lastSentAt", lastSent) + } + } + + companion object { + fun fromJson(json: JSONObject): ServerSessionAttributesState { + val manifest = mutableListOf() + val manifestArray = json.optJSONArray("manifest") + if (manifestArray != null) { + for (i in 0 until manifestArray.length()) { + SAField.fromJson(manifestArray.getJSONObject(i))?.let { manifest.add(it) } + } + } + val lastSentAt = mutableMapOf() + val lastSent = json.optJSONObject("lastSentAt") + if (lastSent != null) { + lastSent.keys().forEach { key -> + lastSentAt[key] = lastSent.optLong(key) + } + } + return ServerSessionAttributesState( + enabled = json.optBoolean("enabled", false), + manifest = manifest, + lastSentAt = lastSentAt, + ) + } + } +} + +private val Context.sessionAttributesDataStore: DataStore by preferencesDataStore( + name = SessionAttributesStore.DATASTORE_NAME, +) + +class SessionAttributesStore(context: Context) { + private val appContext = context.applicationContext + private val lock = Any() + + fun serverKey(serverUrl: String): String { + val normalized = serverUrl.trimEnd('/') + val digest = MessageDigest.getInstance("SHA-256").digest(normalized.toByteArray()) + return digest.joinToString("") { "%02x".format(it) } + } + + private fun stateAlias(serverUrl: String): String { + return "${SessionAttributesConstants.STORE_PREFIX}${serverKey(serverUrl)}-${SessionAttributesConstants.STATE_ALIAS_SUFFIX}" + } + + private fun readValue(alias: String): String? { + val encrypted = runBlocking { + appContext.sessionAttributesDataStore.data.first()[stringPreferencesKey(alias)] + } ?: return null + return try { + KeyStoreHelper.decryptData(encrypted) + } catch (_: Exception) { + null + } + } + + private fun writeValue(alias: String, value: String) { + val encrypted = KeyStoreHelper.encryptData(value) + runBlocking { + appContext.sessionAttributesDataStore.edit { preferences -> + preferences[stringPreferencesKey(alias)] = encrypted + } + } + } + + private fun deleteValue(alias: String) { + runBlocking { + appContext.sessionAttributesDataStore.edit { preferences -> + preferences.remove(stringPreferencesKey(alias)) + } + } + } + + fun loadState(serverUrl: String): ServerSessionAttributesState? = synchronized(lock) { + val raw = readValue(stateAlias(serverUrl)) ?: return null + return try { + ServerSessionAttributesState.fromJson(JSONObject(raw)) + } catch (_: Exception) { + null + } + } + + fun saveState(serverUrl: String, state: ServerSessionAttributesState) = synchronized(lock) { + writeValue(stateAlias(serverUrl), state.toJson().toString()) + } + + fun removeState(serverUrl: String) = synchronized(lock) { + deleteValue(stateAlias(serverUrl)) + } + + fun setEnabled(serverUrl: String, enabled: Boolean) = synchronized(lock) { + val state = loadStateLocked(serverUrl) ?: ServerSessionAttributesState(false, mutableListOf(), mutableMapOf()) + state.enabled = enabled + if (!enabled) { + state.manifest.clear() + state.lastSentAt.clear() + } + writeValue(stateAlias(serverUrl), state.toJson().toString()) + } + + fun setManifest(serverUrl: String, manifest: List) = synchronized(lock) { + val state = loadStateLocked(serverUrl) ?: ServerSessionAttributesState(true, mutableListOf(), mutableMapOf()) + state.enabled = true + state.manifest = manifest.toMutableList() + state.lastSentAt.clear() + writeValue(stateAlias(serverUrl), state.toJson().toString()) + } + + fun upsertField(serverUrl: String, field: SAField) = synchronized(lock) { + val state = loadStateLocked(serverUrl)?.takeIf { it.enabled } ?: return + val index = state.manifest.indexOfFirst { it.name == field.name } + if (index == -1) { + state.manifest.add(field) + } else { + state.manifest[index] = field + } + state.lastSentAt.remove(field.name) + writeValue(stateAlias(serverUrl), state.toJson().toString()) + } + + fun removeField(serverUrl: String, name: String) = synchronized(lock) { + val state = loadStateLocked(serverUrl)?.takeIf { it.enabled } ?: return + state.manifest.removeAll { it.name == name } + state.lastSentAt.remove(name) + writeValue(stateAlias(serverUrl), state.toJson().toString()) + } + + fun setStableValues(values: Map) = synchronized(lock) { + val json = JSONObject() + values.forEach { (key, value) -> json.put(key, value) } + writeValue(SessionAttributesConstants.STABLE_VALUES_ALIAS, json.toString()) + } + + fun getStableValue(name: String): String? = synchronized(lock) { + val raw = readValue(SessionAttributesConstants.STABLE_VALUES_ALIAS) ?: return null + return try { + val value = JSONObject(raw).optString(name, "") + if (value.isEmpty()) null else value + } catch (_: Exception) { + null + } + } + + private fun loadStateLocked(serverUrl: String): ServerSessionAttributesState? { + val raw = readValue(stateAlias(serverUrl)) ?: return null + return try { + ServerSessionAttributesState.fromJson(JSONObject(raw)) + } catch (_: Exception) { + null + } + } + + companion object { + const val DATASTORE_NAME = "SessionAttributesDataStore" + } +} diff --git a/android/src/newarch/java/com/ApiClientModule.kt b/android/src/newarch/java/com/ApiClientModule.kt index bcc259b54..aa6ed3e65 100644 --- a/android/src/newarch/java/com/ApiClientModule.kt +++ b/android/src/newarch/java/com/ApiClientModule.kt @@ -127,4 +127,53 @@ class ApiClientModule(reactContext: ReactApplicationContext) : NativeApiClientSp } implementation.invalidateClientFor(baseUrl, promise) } + + override fun setSessionAttributesEnabled(serverUrl: String?, enabled: Boolean) { + if (serverUrl.isNullOrEmpty()) { + return + } + implementation.setSessionAttributesEnabled(serverUrl, enabled) + } + + override fun removeSessionAttributesServer(serverUrl: String?) { + if (serverUrl.isNullOrEmpty()) { + return + } + implementation.removeSessionAttributesServer(serverUrl) + } + + override fun setSessionAttributesManifest(serverUrl: String?, manifest: String?) { + if (serverUrl.isNullOrEmpty() || manifest == null) { + return + } + implementation.setSessionAttributesManifest(serverUrl, manifest) + } + + override fun upsertSessionAttributesField(serverUrl: String?, field: String?) { + if (serverUrl.isNullOrEmpty() || field == null) { + return + } + implementation.upsertSessionAttributesField(serverUrl, field) + } + + override fun removeSessionAttributesField(serverUrl: String?, name: String?) { + if (serverUrl.isNullOrEmpty() || name.isNullOrEmpty()) { + return + } + implementation.removeSessionAttributesField(serverUrl, name) + } + + override fun setSessionAttributesStableValues(values: String?) { + if (values == null) { + return + } + implementation.setSessionAttributesStableValues(values) + } + + override fun getSessionAttributesHeader(serverUrl: String?): String? { + if (serverUrl.isNullOrEmpty()) { + return null + } + return implementation.getSessionAttributesHeader(serverUrl) + } } diff --git a/android/src/oldarch/java/com/ApiClientModule.kt b/android/src/oldarch/java/com/ApiClientModule.kt index faacf53b9..e1c7137d0 100644 --- a/android/src/oldarch/java/com/ApiClientModule.kt +++ b/android/src/oldarch/java/com/ApiClientModule.kt @@ -72,6 +72,41 @@ class ApiClientModule(reactContext: ReactApplicationContext) : ReactContextBaseJ implementation.cancelRequest(taskId, promise) } + @ReactMethod(isBlockingSynchronousMethod = true) + fun setSessionAttributesEnabled(serverUrl: String, enabled: Boolean) { + implementation.setSessionAttributesEnabled(serverUrl, enabled) + } + + @ReactMethod(isBlockingSynchronousMethod = true) + fun removeSessionAttributesServer(serverUrl: String) { + implementation.removeSessionAttributesServer(serverUrl) + } + + @ReactMethod(isBlockingSynchronousMethod = true) + fun setSessionAttributesManifest(serverUrl: String, manifest: String) { + implementation.setSessionAttributesManifest(serverUrl, manifest) + } + + @ReactMethod(isBlockingSynchronousMethod = true) + fun upsertSessionAttributesField(serverUrl: String, field: String) { + implementation.upsertSessionAttributesField(serverUrl, field) + } + + @ReactMethod(isBlockingSynchronousMethod = true) + fun removeSessionAttributesField(serverUrl: String, name: String) { + implementation.removeSessionAttributesField(serverUrl, name) + } + + @ReactMethod(isBlockingSynchronousMethod = true) + fun setSessionAttributesStableValues(values: String) { + implementation.setSessionAttributesStableValues(values) + } + + @ReactMethod(isBlockingSynchronousMethod = true) + fun getSessionAttributesHeader(serverUrl: String): String? { + return implementation.getSessionAttributesHeader(serverUrl) + } + @ReactMethod fun addListener(eventName: String) { // Keep: Required for RN built in Event Emitter Calls diff --git a/ios/Adapters/SessionAttributesAdapter.swift b/ios/Adapters/SessionAttributesAdapter.swift new file mode 100644 index 000000000..540d2d1a3 --- /dev/null +++ b/ios/Adapters/SessionAttributesAdapter.swift @@ -0,0 +1,27 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import Foundation +import Alamofire + +@objc public class SessionAttributesAdapter: NSObject, RequestAdapter { + public func adapt(_ urlRequest: URLRequest, for session: Session, completion: @escaping (Result) -> Void) { + guard let baseUrl = session.baseUrl else { + completion(.success(urlRequest)) + return + } + + var urlRequest = urlRequest + + let hasAuthorization = urlRequest.value(forHTTPHeaderField: "Authorization") != nil + let hasSessionAttributes = urlRequest.value(forHTTPHeaderField: SessionAttributesConstants.headerName) != nil + + if hasAuthorization, + !hasSessionAttributes, + let header = SessionAttributes.getOutboundHeader(baseUrl.absoluteString) { + urlRequest.setValue(header, forHTTPHeaderField: SessionAttributesConstants.headerName) + } + + completion(.success(urlRequest)) + } +} diff --git a/ios/ApiClient/ApiClient.mm b/ios/ApiClient/ApiClient.mm index 8c86f94f2..0572f8b1e 100644 --- a/ios/ApiClient/ApiClient.mm +++ b/ios/ApiClient/ApiClient.mm @@ -97,6 +97,42 @@ -(void)stopObserving { [wrapper cancelRequest:taskId withResolver:resolve withRejecter:reject]; } +#ifndef RCT_NEW_ARCH_ENABLED +RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(setSessionAttributesEnabled:(NSString *)serverUrl enabled:(BOOL)enabled) { + [wrapper setSessionAttributesEnabled:serverUrl enabled:enabled]; + return nil; +} + +RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(removeSessionAttributesServer:(NSString *)serverUrl) { + [wrapper removeSessionAttributesServer:serverUrl]; + return nil; +} + +RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(setSessionAttributesManifest:(NSString *)serverUrl manifest:(NSString *)manifest) { + [wrapper setSessionAttributesManifest:serverUrl manifest:manifest]; + return nil; +} + +RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(upsertSessionAttributesField:(NSString *)serverUrl field:(NSString *)field) { + [wrapper upsertSessionAttributesField:serverUrl field:field]; + return nil; +} + +RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(removeSessionAttributesField:(NSString *)serverUrl name:(NSString *)name) { + [wrapper removeSessionAttributesField:serverUrl name:name]; + return nil; +} + +RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(setSessionAttributesStableValues:(NSString *)values) { + [wrapper setSessionAttributesStableValues:values]; + return nil; +} + +RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(getSessionAttributesHeader:(NSString *)serverUrl) { + return [wrapper getSessionAttributesHeader:serverUrl]; +} +#endif + #ifdef RCT_NEW_ARCH_ENABLED - (std::shared_ptr)getTurboModule: (const facebook::react::ObjCTurboModule::InitParams &)params @@ -191,6 +227,34 @@ - (void)upload:(NSString *)baseUrl endpoint:(NSString * _Nullable)endpoint fileU [wrapper uploadWithBaseUrlString:baseUrl endpoint:endpoint fileUrlString:fileUrl taskId:taskId options:opts resolve:resolve reject:reject]; } +- (void)setSessionAttributesEnabled:(NSString *)serverUrl enabled:(BOOL)enabled { + [wrapper setSessionAttributesEnabled:serverUrl enabled:enabled]; +} + +- (void)removeSessionAttributesServer:(NSString *)serverUrl { + [wrapper removeSessionAttributesServer:serverUrl]; +} + +- (void)setSessionAttributesManifest:(NSString *)serverUrl manifest:(NSString *)manifest { + [wrapper setSessionAttributesManifest:serverUrl manifest:manifest]; +} + +- (void)upsertSessionAttributesField:(NSString *)serverUrl field:(NSString *)field { + [wrapper upsertSessionAttributesField:serverUrl field:field]; +} + +- (void)removeSessionAttributesField:(NSString *)serverUrl name:(NSString *)name { + [wrapper removeSessionAttributesField:serverUrl name:name]; +} + +- (void)setSessionAttributesStableValues:(NSString *)values { + [wrapper setSessionAttributesStableValues:values]; +} + +- (NSString *)getSessionAttributesHeader:(NSString *)serverUrl { + return [wrapper getSessionAttributesHeader:serverUrl]; +} + #pragma utils - (NSNumber *)processBooleanValue:(std::optional)optionalBoolValue { diff --git a/ios/ApiClient/ApiClientWrapper.swift b/ios/ApiClient/ApiClientWrapper.swift index ae353c39f..03bc9e1d3 100644 --- a/ios/ApiClient/ApiClientWrapper.swift +++ b/ios/ApiClient/ApiClientWrapper.swift @@ -334,6 +334,34 @@ import React request.cancel() } } + + @objc public func setSessionAttributesEnabled(_ serverUrl: String, enabled: Bool) { + SessionAttributesEngine.shared.setEnabled(serverUrl, enabled: enabled) + } + + @objc public func removeSessionAttributesServer(_ serverUrl: String) { + SessionAttributesEngine.shared.removeServer(serverUrl) + } + + @objc public func setSessionAttributesManifest(_ serverUrl: String, manifest: String) { + SessionAttributesEngine.shared.setManifest(serverUrl, manifestJson: manifest) + } + + @objc public func upsertSessionAttributesField(_ serverUrl: String, field: String) { + SessionAttributesEngine.shared.upsertManifestField(serverUrl, fieldJson: field) + } + + @objc public func removeSessionAttributesField(_ serverUrl: String, name: String) { + SessionAttributesEngine.shared.removeManifestField(serverUrl, name: name) + } + + @objc public func setSessionAttributesStableValues(_ values: String) { + SessionAttributesEngine.shared.setStableValues(values) + } + + @objc public func getSessionAttributesHeader(_ serverUrl: String) -> String? { + return SessionAttributesEngine.shared.getOutboundHeader(serverUrl) + } func handleRequest(for baseUrlString: String, withEndpoint endpoint: String, withMethod method: HTTPMethod, withOptions options: JSON, withResolver resolve: @escaping RCTPromiseResolveBlock, withRejecter reject: @escaping RCTPromiseRejectBlock) -> Void { guard let baseUrl = URL(string: baseUrlString) else { diff --git a/ios/NetworkClient.swift b/ios/NetworkClient.swift index 894935f24..da274b27d 100644 --- a/ios/NetworkClient.swift +++ b/ios/NetworkClient.swift @@ -294,10 +294,9 @@ extension NetworkClient { adapters.append(BearerAuthenticationAdapter()) } - if (adapters.isEmpty) { - return Interceptor(retriers: retriers) - } - + // Must run after BearerAuthenticationAdapter so the Authorization header is present. + adapters.append(SessionAttributesAdapter()) + return Interceptor(adapters: adapters, retriers: retriers) } diff --git a/ios/SessionAttributes/SessionAttributes.swift b/ios/SessionAttributes/SessionAttributes.swift new file mode 100644 index 000000000..bd31fb48c --- /dev/null +++ b/ios/SessionAttributes/SessionAttributes.swift @@ -0,0 +1,13 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import Foundation + +/// React-free entry point for native code outside this library (e.g. Gekidou, +/// app extensions, standalone URLSession/OkHttp usage) to resolve the outbound +/// `X-MM-Session-Attributes` header for a server. +@objc public class SessionAttributes: NSObject { + @objc public static func getOutboundHeader(_ serverUrl: String) -> String? { + return SessionAttributesEngine.shared.getOutboundHeader(serverUrl) + } +} diff --git a/ios/SessionAttributes/SessionAttributesCollector.swift b/ios/SessionAttributes/SessionAttributesCollector.swift new file mode 100644 index 000000000..33fcc08a9 --- /dev/null +++ b/ios/SessionAttributes/SessionAttributesCollector.swift @@ -0,0 +1,227 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import Foundation +import Network +import NetworkExtension +import SystemConfiguration.CaptiveNetwork + +struct NetworkSnapshot { + let interfaceType: String + let ipAddress: String + let vpnActive: Bool + let ssid: String +} + +public class SessionAttributesCollector { + public static let shared = SessionAttributesCollector() + + private let store = SessionAttributesStore.shared + private var fqdnCache: [String: String] = [:] + + private let monitorQueue = DispatchQueue(label: "com.mattermost.networkclient.sessionattributes.network") + private let pathMonitor = NWPathMonitor() + private var cachedInterfaceType = "" + private var cachedSsid = "" + private let ssidUnavailable: Bool + + private init() { + ssidUnavailable = Bundle.main.bundlePath.hasSuffix(".appex") && + Bundle.main.bundleIdentifier?.contains("NotificationService") == true + + pathMonitor.pathUpdateHandler = { [weak self] path in + guard let self else { + return + } + if path.usesInterfaceType(.wifi) { + self.cachedInterfaceType = "wifi" + } else if path.usesInterfaceType(.cellular) { + self.cachedInterfaceType = "cellular" + } else if path.usesInterfaceType(.wiredEthernet) { + self.cachedInterfaceType = "ethernet" + } else if path.status == .unsatisfied { + self.cachedInterfaceType = "" + } else { + self.cachedInterfaceType = "other" + } + + if self.cachedInterfaceType == "wifi" { + self.refreshSsid() + } else { + self.cachedSsid = "" + } + } + pathMonitor.start(queue: monitorQueue) + } + + func collect(_ name: String, serverUrl: String) -> String { + if let stableValue = store.getStableValue(name), !stableValue.isEmpty { + return stableValue + } + + switch name { + case SessionAttributesConstants.AttributeKey.clientIpAddress: + return currentNetworkSnapshot().ipAddress + case SessionAttributesConstants.AttributeKey.networkInterfaceType: + return currentNetworkSnapshot().interfaceType + case SessionAttributesConstants.AttributeKey.vpnActive: + return currentNetworkSnapshot().vpnActive ? "true" : "false" + case SessionAttributesConstants.AttributeKey.ssid: + return currentNetworkSnapshot().ssid + case SessionAttributesConstants.AttributeKey.mdmEnrolled: + return isMdmEnrolled() ? "true" : "false" + case SessionAttributesConstants.AttributeKey.serverFqdn: + return getServerFqdn(serverUrl) + default: + return "" + } + } + + private func getServerFqdn(_ serverUrl: String) -> String { + if let cached = fqdnCache[serverUrl] { + return cached + } + guard let url = URL(string: serverUrl), let host = url.host, !host.isEmpty else { + fqdnCache[serverUrl] = "" + return "" + } + fqdnCache[serverUrl] = host + return host + } + + private func isMdmEnrolled() -> Bool { + guard let managedConfig = UserDefaults.standard.dictionary(forKey: SessionAttributesConstants.managedConfigKey) else { + return false + } + return isTruthyManagedFlag(managedConfig[SessionAttributesConstants.isDeviceManagedKey]) || + isTruthyManagedFlag(managedConfig[SessionAttributesConstants.isSupervisedKey]) + } + + private func isTruthyManagedFlag(_ value: Any?) -> Bool { + guard let value else { + return false + } + if let bool = value as? Bool { + return bool + } + if let number = value as? NSNumber { + return number.boolValue + } + if let string = value as? String { + let normalized = string.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return normalized == "true" || normalized == "1" || normalized == "yes" + } + return false + } + + func currentNetworkSnapshot() -> NetworkSnapshot { + let vpnActive = hasVpnInterface() + let ipAddress = resolveIpAddress() + return monitorQueue.sync { + let interfaceType = vpnActive ? "vpn" : cachedInterfaceType + let ssid = interfaceType == "wifi" ? cachedSsid : "" + return NetworkSnapshot( + interfaceType: interfaceType, + ipAddress: ipAddress, + vpnActive: vpnActive, + ssid: ssid + ) + } + } + + private func refreshSsid() { + guard !ssidUnavailable else { + cachedSsid = "" + return + } + + if #available(iOS 14.0, *) { + NEHotspotNetwork.fetchCurrent { [weak self] network in + guard let self else { + return + } + let ssid: String + if let networkSSID = network?.ssid, + networkSSID != "Wi-Fi", + networkSSID != "WLAN" { + ssid = networkSSID + } else { + ssid = "" + } + self.monitorQueue.async { + self.cachedSsid = ssid + } + } + return + } + + cachedSsid = resolveLegacySsid() + } + + private func resolveLegacySsid() -> String { + guard let interfaces = CNCopySupportedInterfaces() as? [String] else { + return "" + } + for interface in interfaces { + if let info = CNCopyCurrentNetworkInfo(interface as CFString) as? [String: AnyObject], + let networkSSID = info[kCNNetworkInfoKeySSID as String] as? String, + networkSSID != "Wi-Fi", + networkSSID != "WLAN" { + return networkSSID + } + } + return "" + } + + private func hasVpnInterface() -> Bool { + var interfaces: UnsafeMutablePointer? + guard getifaddrs(&interfaces) == 0, let first = interfaces else { + return false + } + defer { freeifaddrs(interfaces) } + + var ptr = first + while true { + let name = String(cString: ptr.pointee.ifa_name) + if name.hasPrefix("utun") || name.hasPrefix("ipsec") || name.hasPrefix("ppp") { + return true + } + guard let next = ptr.pointee.ifa_next else { + break + } + ptr = next + } + return false + } + + private func resolveIpAddress() -> String { + var address = "" + var interfaces: UnsafeMutablePointer? + guard getifaddrs(&interfaces) == 0, let first = interfaces else { + return address + } + defer { freeifaddrs(interfaces) } + + var ptr = first + while true { + let interface = ptr.pointee + if let ifaAddr = interface.ifa_addr, ifaAddr.pointee.sa_family == UInt8(AF_INET) { + let name = String(cString: interface.ifa_name) + if name == "en0" || name == "en1" { + var addr = ifaAddr.pointee + var buffer = [CChar](repeating: 0, count: Int(INET_ADDRSTRLEN)) + inet_ntop(AF_INET, &addr, &buffer, socklen_t(INET_ADDRSTRLEN)) + let ip = String(cString: buffer) + if ip != "0.0.0.0" { + address = ip + } + } + } + guard let next = interface.ifa_next else { + break + } + ptr = next + } + return address + } +} diff --git a/ios/SessionAttributes/SessionAttributesConstants.swift b/ios/SessionAttributes/SessionAttributesConstants.swift new file mode 100644 index 000000000..9c8eb38ba --- /dev/null +++ b/ios/SessionAttributes/SessionAttributesConstants.swift @@ -0,0 +1,27 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import Foundation + +public struct SessionAttributesConstants { + public static let headerName = "X-MM-Session-Attributes" + public static let storePrefix = "sa_" + public static let stableValuesKey = "sa_stable_values" + public static let managedConfigKey = "com.apple.configuration.managed" + public static let isDeviceManagedKey = "isDeviceManaged" + public static let isSupervisedKey = "isSupervised" + + public struct AttributeKey { + public static let vpnActive = "vpn_active" + public static let clientDeviceId = "client_device_id" + public static let clientIpAddress = "client_ip_address" + public static let clientVersion = "client_version" + public static let jailbreakDetected = "jailbreak_detected" + public static let mdmEnrolled = "mdm_enrolled" + public static let networkInterfaceType = "network_interface_type" + public static let osPlatform = "os_platform" + public static let osVersion = "os_version" + public static let serverFqdn = "server_fqdn" + public static let ssid = "ssid" + } +} diff --git a/ios/SessionAttributes/SessionAttributesEngine.swift b/ios/SessionAttributes/SessionAttributesEngine.swift new file mode 100644 index 000000000..ae92670e8 --- /dev/null +++ b/ios/SessionAttributes/SessionAttributesEngine.swift @@ -0,0 +1,120 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import Foundation + +@objc public class SessionAttributesEngine: NSObject { + @objc public static let shared = SessionAttributesEngine() + + private let store = SessionAttributesStore.shared + private let collector = SessionAttributesCollector.shared + + private override init() {} + + @objc public func setEnabled(_ serverUrl: String, enabled: Bool) { + store.setEnabled(enabled, for: serverUrl) + } + + @objc public func removeServer(_ serverUrl: String) { + store.removeState(for: serverUrl) + } + + @objc public func setManifest(_ serverUrl: String, manifestJson: String) { + let fields = decodeArray(manifestJson).compactMap { parseField($0) } + guard !fields.isEmpty else { + removeServer(serverUrl) + return + } + store.setManifest(fields, for: serverUrl) + } + + @objc public func upsertManifestField(_ serverUrl: String, fieldJson: String) { + guard let dictionary = decodeObject(fieldJson), let parsed = parseField(dictionary) else { + return + } + store.upsertField(parsed, for: serverUrl) + } + + @objc public func removeManifestField(_ serverUrl: String, name: String) { + store.removeField(name, for: serverUrl) + } + + @objc public func setStableValues(_ valuesJson: String) { + guard let data = valuesJson.data(using: .utf8), + let decoded = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return + } + let values = decoded.compactMapValues { value -> String? in + if let string = value as? String { + return string + } + return nil + } + store.setStableValues(values) + } + + @objc public func getOutboundHeader(_ serverUrl: String) -> String? { + guard let state = store.loadState(for: serverUrl), state.enabled, !state.manifest.isEmpty else { + return nil + } + + 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] + let shouldSend = lastSent == nil || field.ttl_seconds == 0 || (now - lastSent!) >= Double(field.ttl_seconds) * 1000 + if !shouldSend { + continue + } + + let value = collector.collect(field.name, serverUrl: serverUrl) + guard !value.isEmpty else { + continue + } + + payload[field.name] = value + updatedLastSentAt[field.name] = now + } + + guard !payload.isEmpty else { + return nil + } + + store.updateLastSentAt(updatedLastSentAt, for: serverUrl) + + guard let jsonData = try? JSONSerialization.data(withJSONObject: payload, options: []), + let jsonString = String(data: jsonData, encoding: .utf8) else { + return nil + } + + return Data(jsonString.utf8).base64EncodedString() + } + + 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 { + return [] + } + return array + } + + private func decodeObject(_ json: String) -> [String: Any]? { + guard let data = json.data(using: .utf8) else { + return nil + } + return try? JSONSerialization.jsonObject(with: data) as? [String: Any] + } + + private func parseField(_ dictionary: [String: Any]) -> SAField? { + guard let name = dictionary["name"] as? String, + let type = dictionary["type"] as? String else { + return nil + } + let ttl = dictionary["ttl_seconds"] as? Int ?? (dictionary["ttl_seconds"] as? Double).map { Int($0) } ?? 0 + let grace = dictionary["grace_period_seconds"] as? Int ?? (dictionary["grace_period_seconds"] as? Double).map { Int($0) } ?? 0 + return SAField(name: name, type: type, ttl_seconds: ttl, grace_period_seconds: grace) + } +} diff --git a/ios/SessionAttributes/SessionAttributesStore.swift b/ios/SessionAttributes/SessionAttributesStore.swift new file mode 100644 index 000000000..245473651 --- /dev/null +++ b/ios/SessionAttributes/SessionAttributesStore.swift @@ -0,0 +1,149 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import Foundation +import CryptoKit + +struct SAField: Codable, Equatable { + let name: String + let type: String + let ttl_seconds: Int + let grace_period_seconds: Int +} + +struct ServerSessionAttributesState: Codable { + var enabled: Bool + var manifest: [SAField] + var lastSentAt: [String: Double] +} + +public class SessionAttributesStore { + public static let shared = SessionAttributesStore() + + private let userDefaults: UserDefaults? + private let queue = DispatchQueue(label: "com.mattermost.networkclient.sessionattributes.store") + + private init() { + let appGroupId = Bundle.main.object(forInfoDictionaryKey: "AppGroupIdentifier") as? String + userDefaults = appGroupId != nil ? UserDefaults(suiteName: appGroupId) : UserDefaults.standard + } + + func serverKey(_ serverUrl: String) -> String { + let normalized = serverUrl.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + let digest = SHA256.hash(data: Data(normalized.utf8)) + return digest.compactMap { String(format: "%02x", $0) }.joined() + } + + private func stateKey(for serverUrl: String) -> String { + return "\(SessionAttributesConstants.storePrefix)state_\(serverKey(serverUrl))" + } + + private func readState(for serverUrl: String) -> ServerSessionAttributesState? { + guard let data = userDefaults?.object(forKey: stateKey(for: serverUrl)) as? Data else { + return nil + } + return try? JSONDecoder().decode(ServerSessionAttributesState.self, from: data) + } + + private func writeState(_ state: ServerSessionAttributesState, for serverUrl: String) { + guard let data = try? JSONEncoder().encode(state) else { + return + } + userDefaults?.set(data, forKey: stateKey(for: serverUrl)) + } + + func loadState(for serverUrl: String) -> ServerSessionAttributesState? { + queue.sync { + readState(for: serverUrl) + } + } + + func saveState(_ state: ServerSessionAttributesState, for serverUrl: String) { + queue.sync { + writeState(state, for: serverUrl) + } + } + + func removeState(for serverUrl: String) { + queue.sync { + userDefaults?.removeObject(forKey: stateKey(for: serverUrl)) + } + } + + func setEnabled(_ enabled: Bool, for serverUrl: String) { + queue.sync { + var state = readState(for: serverUrl) ?? ServerSessionAttributesState(enabled: false, manifest: [], lastSentAt: [:]) + state.enabled = enabled + if !enabled { + state.manifest = [] + state.lastSentAt = [:] + } + writeState(state, for: serverUrl) + } + } + + func setManifest(_ manifest: [SAField], for serverUrl: String) { + queue.sync { + var state = readState(for: serverUrl) ?? ServerSessionAttributesState(enabled: true, manifest: [], lastSentAt: [:]) + state.enabled = true + state.manifest = manifest + state.lastSentAt = [:] + writeState(state, for: serverUrl) + } + } + + func upsertField(_ field: SAField, for serverUrl: String) { + queue.sync { + guard var state = readState(for: serverUrl), state.enabled else { + return + } + if let index = state.manifest.firstIndex(where: { $0.name == field.name }) { + state.manifest[index] = field + } else { + state.manifest.append(field) + } + state.lastSentAt.removeValue(forKey: field.name) + writeState(state, for: serverUrl) + } + } + + func removeField(_ name: String, for serverUrl: String) { + queue.sync { + guard var state = readState(for: serverUrl), state.enabled else { + return + } + state.manifest.removeAll { $0.name == name } + state.lastSentAt.removeValue(forKey: name) + writeState(state, for: serverUrl) + } + } + + func updateLastSentAt(_ lastSentAt: [String: Double], for serverUrl: String) { + queue.sync { + guard var state = readState(for: serverUrl) else { + return + } + state.lastSentAt = lastSentAt + writeState(state, for: serverUrl) + } + } + + func setStableValues(_ values: [String: String]) { + queue.sync { + guard let data = try? JSONEncoder().encode(values) else { + return + } + userDefaults?.set(data, forKey: SessionAttributesConstants.stableValuesKey) + } + } + + func getStableValue(_ name: String) -> String? { + queue.sync { + guard let data = userDefaults?.object(forKey: SessionAttributesConstants.stableValuesKey) as? Data, + let values = try? JSONDecoder().decode([String: String].self, from: data) else { + return nil + } + return values[name] + } + } +} diff --git a/ios/patches/apply_patches.rb b/ios/patches/apply_patches.rb index 4663f3788..652ce1c1d 100644 --- a/ios/patches/apply_patches.rb +++ b/ios/patches/apply_patches.rb @@ -68,7 +68,12 @@ def patch_additions_present?(file, repo_root, directory_arg) def apply_patch(file) repo_root = `git rev-parse --show-toplevel`.strip - directory_arg = Dir.glob(Pathname(repo_root).join("**/**/Pods")).first.sub("#{repo_root}/", "") + pods_dir = Dir.glob(Pathname(repo_root).join("**/**/Pods")).first + unless pods_dir + Pod::UI.puts "Skipping #{file} (Pods directory not found yet)" + return + end + directory_arg = pods_dir.sub("#{repo_root}/", "") Dir.chdir(repo_root) { base_args = "'#{file}' --directory='#{directory_arg}' -p2 2> /dev/null" diff --git a/package-lock.json b/package-lock.json index deff72bb0..d0c92742c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@mattermost/react-native-network-client", - "version": "1.10.3", + "version": "1.11.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@mattermost/react-native-network-client", - "version": "1.10.3", + "version": "1.11.0", "license": "MIT", "dependencies": { "validator": "13.15.35", diff --git a/package.json b/package.json index 87345b8cc..3e9a7008e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@mattermost/react-native-network-client", - "version": "1.10.3", + "version": "1.11.0", "description": "Configurable network clients for React Native. Uses Alamofire for iOS and OkHttp3 for Android.", "main": "lib/commonjs/index", "module": "lib/module/index", diff --git a/react-native-network-client.podspec b/react-native-network-client.podspec index 6a405ceb2..f4d1a48e8 100644 --- a/react-native-network-client.podspec +++ b/react-native-network-client.podspec @@ -12,31 +12,43 @@ Pod::Spec.new do |s| s.platforms = { :ios => "15.1" } s.source = { :git => "https://github.com/mattermost/react-native-network-client.git", :tag => "#{s.version}" } - - - s.source_files = "ios/**/*.{h,m,mm,swift}" s.prepare_command = 'ruby ios/patches/apply_patches.rb' - - fabric_enabled = ENV["RCT_NEW_ARCH_ENABLED"] == "1" - - if fabric_enabled - s.pod_target_xcconfig = { - "DEFINES_MODULE" => "YES", - "BUILD_LIBRARY_FOR_DISTRIBUTION" => "YES", - "OTHER_CPLUSPLUSFLAGS" => "-DRCT_NEW_ARCH_ENABLED=1", - "OTHER_SWIFT_FLAGS" => "-no-verify-emitted-module-interface" - } - else - s.pod_target_xcconfig = { - "DEFINES_MODULE" => "YES", - "BUILD_LIBRARY_FOR_DISTRIBUTION" => "YES", - "OTHER_SWIFT_FLAGS" => "-no-verify-emitted-module-interface" - } + + # SessionAttributes lives in its own React-free subspec so it can be linked by + # app extensions / standalone native code without pulling in React. + s.subspec 'SessionAttributes' do |sa| + sa.source_files = 'ios/SessionAttributes/**/*.swift' + sa.pod_target_xcconfig = { 'BUILD_LIBRARY_FOR_DISTRIBUTION' => 'YES' } end - install_modules_dependencies(s) + s.subspec 'Core' do |core| + core.source_files = "ios/**/*.{h,m,mm,swift}" + core.exclude_files = "ios/SessionAttributes/**/*" + core.dependency 'react-native-network-client/SessionAttributes' + + fabric_enabled = ENV["RCT_NEW_ARCH_ENABLED"] == "1" + + if fabric_enabled + core.pod_target_xcconfig = { + "DEFINES_MODULE" => "YES", + "BUILD_LIBRARY_FOR_DISTRIBUTION" => "YES", + "OTHER_CPLUSPLUSFLAGS" => "-DRCT_NEW_ARCH_ENABLED=1", + "OTHER_SWIFT_FLAGS" => "-no-verify-emitted-module-interface" + } + else + core.pod_target_xcconfig = { + "DEFINES_MODULE" => "YES", + "BUILD_LIBRARY_FOR_DISTRIBUTION" => "YES", + "OTHER_SWIFT_FLAGS" => "-no-verify-emitted-module-interface" + } + end + + install_modules_dependencies(core) + + core.dependency "Alamofire", "~> 5.11.2" + core.dependency "SwiftyJSON", "~> 5.0.2" + core.dependency "Starscream", "~> 4.0.8" + end - s.dependency "Alamofire", "~> 5.11.2" - s.dependency "SwiftyJSON", "~> 5.0.2" - s.dependency "Starscream", "~> 4.0.8" + s.default_subspec = 'Core' end diff --git a/src/APIClient/NativeApiClient.ts b/src/APIClient/NativeApiClient.ts index b7bad83ad..a48f14682 100644 --- a/src/APIClient/NativeApiClient.ts +++ b/src/APIClient/NativeApiClient.ts @@ -165,6 +165,14 @@ export interface Spec extends TurboModule { password?: string, ): Promise; invalidateClientFor(baseUrl: string): Promise; + + setSessionAttributesEnabled(serverUrl: string, enabled: boolean): void; + removeSessionAttributesServer(serverUrl: string): void; + setSessionAttributesManifest(serverUrl: string, manifest: string): void; + upsertSessionAttributesField(serverUrl: string, field: string): void; + removeSessionAttributesField(serverUrl: string, name: string): void; + setSessionAttributesStableValues(values: string): void; + getSessionAttributesHeader(serverUrl: string): string | undefined; } export default TurboModuleRegistry.get("ApiClient") as Spec; diff --git a/src/SessionAttributes/index.tsx b/src/SessionAttributes/index.tsx new file mode 100644 index 000000000..1fb0cff1d --- /dev/null +++ b/src/SessionAttributes/index.tsx @@ -0,0 +1,61 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import NativeApiClient from "../APIClient/NativeApiClient"; + +export type SessionAttributeField = { + name: string; + type: string; + ttl_seconds?: number; + grace_period_seconds?: number; +}; + +export const setSessionAttributesEnabled = ( + serverUrl: string, + enabled: boolean, +): void => { + NativeApiClient.setSessionAttributesEnabled(serverUrl, enabled); +}; + +export const removeSessionAttributesServer = (serverUrl: string): void => { + NativeApiClient.removeSessionAttributesServer(serverUrl); +}; + +export const setSessionAttributesManifest = ( + serverUrl: string, + manifest: SessionAttributeField[], +): void => { + NativeApiClient.setSessionAttributesManifest( + serverUrl, + JSON.stringify(manifest), + ); +}; + +export const upsertSessionAttributesField = ( + serverUrl: string, + field: SessionAttributeField, +): void => { + NativeApiClient.upsertSessionAttributesField( + serverUrl, + JSON.stringify(field), + ); +}; + +export const removeSessionAttributesField = ( + serverUrl: string, + name: string, +): void => { + NativeApiClient.removeSessionAttributesField(serverUrl, name); +}; + +export const setSessionAttributesStableValues = ( + values: Record, +): void => { + NativeApiClient.setSessionAttributesStableValues(JSON.stringify(values)); +}; + +export const getSessionAttributesHeader = ( + serverUrl: string, +): string | undefined => { + return NativeApiClient.getSessionAttributesHeader(serverUrl) ?? undefined; +}; diff --git a/src/index.tsx b/src/index.tsx index 33aa1ec07..364157bf1 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -7,6 +7,7 @@ import { getOrCreateWebSocketClient } from "./WebSocketClient"; export * from "./types/APIClient"; export * from "./types/WebSocketClient"; +export * from "./SessionAttributes"; export { getOrCreateAPIClient, getOrCreateWebSocketClient }; export { RetryTypes } from "./APIClient/NativeApiClient"; export { diff --git a/test-runner/src/test/kotlin/com/mattermost/networkclient/SessionAttributesTest.kt b/test-runner/src/test/kotlin/com/mattermost/networkclient/SessionAttributesTest.kt new file mode 100644 index 000000000..acb98ee9d --- /dev/null +++ b/test-runner/src/test/kotlin/com/mattermost/networkclient/SessionAttributesTest.kt @@ -0,0 +1,216 @@ +package com.mattermost.networkclient + +import okhttp3.Interceptor +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.Assert +import org.junit.Test +import java.io.IOException + +/** + * Self-contained session attributes tests that run on the JVM without Android dependencies. + * + * The engine TTL logic, collector interface-type mapping and interceptor guard are reproduced + * inline here (matching + * android/src/main/java/com/mattermost/networkclient/sessionattributes and + * interceptors/SessionAttributesInterceptor.kt) so these tests can run in the pure-JVM + * test-runner without the Android SDK. + */ +class SessionAttributesTest { + + // --------------------------------------------------------------------------- + // Inline reproductions matching the real implementation + // --------------------------------------------------------------------------- + + private fun shouldSend(lastSent: Long?, ttlSeconds: Int, now: Long): Boolean { + return lastSent == null || ttlSeconds == 0 || (now - lastSent) >= ttlSeconds * 1000L + } + + private fun interfaceType( + vpn: Boolean, + wifi: Boolean, + cellular: Boolean, + ethernet: Boolean, + hasActiveNetwork: Boolean, + ): String = when { + vpn -> "vpn" + wifi -> "wifi" + cellular -> "cellular" + ethernet -> "ethernet" + !hasActiveNetwork -> "" + else -> "other" + } + + private class InlineSessionAttributesInterceptor( + private val serverUrl: String, + private val headerProvider: (String) -> String?, + ) : Interceptor { + @Throws(IOException::class) + override fun intercept(chain: Interceptor.Chain): Response { + val request = chain.request() + val hasAuthorization = request.header("Authorization") != null + val hasSessionAttributes = request.header(HEADER_NAME) != null + if (!hasAuthorization || hasSessionAttributes) { + return chain.proceed(request) + } + val header = headerProvider(serverUrl) ?: return chain.proceed(request) + return chain.proceed(request.newBuilder().header(HEADER_NAME, header).build()) + } + + companion object { + const val HEADER_NAME = "X-MM-Session-Attributes" + } + } + + // --------------------------------------------------------------------------- + // Engine TTL logic + // --------------------------------------------------------------------------- + + @Test + fun ttl_sendsWhenNeverSent() { + Assert.assertTrue(shouldSend(lastSent = null, ttlSeconds = 3600, now = 1_000_000)) + } + + @Test + fun ttl_alwaysSendsWhenTtlZero() { + Assert.assertTrue(shouldSend(lastSent = 999_999, ttlSeconds = 0, now = 1_000_000)) + } + + @Test + fun ttl_doesNotSendWithinWindow() { + // lastSent 10s ago, ttl 60s -> should not send + Assert.assertFalse(shouldSend(lastSent = 990_000, ttlSeconds = 60, now = 1_000_000)) + } + + @Test + fun ttl_sendsWhenWindowElapsed() { + // lastSent 61s ago, ttl 60s -> should send + Assert.assertTrue(shouldSend(lastSent = 939_000, ttlSeconds = 60, now = 1_000_000)) + } + + // --------------------------------------------------------------------------- + // Collector snapshot mapping + // --------------------------------------------------------------------------- + + @Test + fun collector_vpnTakesPrecedence() { + Assert.assertEquals( + "vpn", + interfaceType(vpn = true, wifi = true, cellular = false, ethernet = false, hasActiveNetwork = true), + ) + } + + @Test + fun collector_mapsTransports() { + Assert.assertEquals("wifi", interfaceType(false, wifi = true, cellular = false, ethernet = false, hasActiveNetwork = true)) + Assert.assertEquals("cellular", interfaceType(false, wifi = false, cellular = true, ethernet = false, hasActiveNetwork = true)) + Assert.assertEquals("ethernet", interfaceType(false, wifi = false, cellular = false, ethernet = true, hasActiveNetwork = true)) + } + + @Test + fun collector_emptyWhenNoActiveNetwork() { + Assert.assertEquals( + "", + interfaceType(vpn = false, wifi = false, cellular = false, ethernet = false, hasActiveNetwork = false), + ) + } + + @Test + fun collector_otherWhenUnknownTransport() { + Assert.assertEquals( + "other", + interfaceType(vpn = false, wifi = false, cellular = false, ethernet = false, hasActiveNetwork = true), + ) + } + + // --------------------------------------------------------------------------- + // Interceptor guard + // --------------------------------------------------------------------------- + + @Test + fun interceptor_addsHeaderWhenAuthorizationPresent() { + MockWebServer().use { server -> + server.enqueue(MockResponse().setResponseCode(200)) + server.start() + + val client = OkHttpClient().newBuilder() + .addInterceptor(InlineSessionAttributesInterceptor(server.url("/").toString()) { "encoded==" }) + .build() + val request = Request.Builder() + .url(server.url("/api")) + .header("Authorization", "Bearer token") + .build() + + client.newCall(request).execute().close() + + val recorded = server.takeRequest() + Assert.assertEquals("encoded==", recorded.getHeader("X-MM-Session-Attributes")) + } + } + + @Test + fun interceptor_doesNotAddHeaderWhenNoAuthorization() { + MockWebServer().use { server -> + server.enqueue(MockResponse().setResponseCode(200)) + server.start() + + val client = OkHttpClient().newBuilder() + .addInterceptor(InlineSessionAttributesInterceptor(server.url("/").toString()) { "encoded==" }) + .build() + val request = Request.Builder() + .url(server.url("/api")) + .build() + + client.newCall(request).execute().close() + + val recorded = server.takeRequest() + Assert.assertNull(recorded.getHeader("X-MM-Session-Attributes")) + } + } + + @Test + fun interceptor_doesNotOverrideExistingHeader() { + MockWebServer().use { server -> + server.enqueue(MockResponse().setResponseCode(200)) + server.start() + + val client = OkHttpClient().newBuilder() + .addInterceptor(InlineSessionAttributesInterceptor(server.url("/").toString()) { "engine==" }) + .build() + val request = Request.Builder() + .url(server.url("/api")) + .header("Authorization", "Bearer token") + .header("X-MM-Session-Attributes", "preset==") + .build() + + client.newCall(request).execute().close() + + val recorded = server.takeRequest() + Assert.assertEquals("preset==", recorded.getHeader("X-MM-Session-Attributes")) + } + } + + @Test + fun interceptor_doesNotAddHeaderWhenEngineReturnsNull() { + MockWebServer().use { server -> + server.enqueue(MockResponse().setResponseCode(200)) + server.start() + + val client = OkHttpClient().newBuilder() + .addInterceptor(InlineSessionAttributesInterceptor(server.url("/").toString()) { null }) + .build() + val request = Request.Builder() + .url(server.url("/api")) + .header("Authorization", "Bearer token") + .build() + + client.newCall(request).execute().close() + + val recorded = server.takeRequest() + Assert.assertNull(recorded.getHeader("X-MM-Session-Attributes")) + } + } +} From 17be625d49231017bd3f50cc7010bbfd510beb38 Mon Sep 17 00:00:00 2001 From: Devin Binnie Date: Wed, 5 Aug 2026 11:40:24 -0400 Subject: [PATCH 2/3] chore: pr feedback --- .../networkclient/ApiClientModuleImpl.kt | 15 +++---- .../mattermost/networkclient/NetworkClient.kt | 24 +++++++++-- .../SessionAttributesInterceptor.kt | 10 ++--- .../sessionattributes/SessionAttributes.kt | 13 +++++- .../SessionAttributesCollector.kt | 10 +++-- .../SessionAttributesEngine.kt | 41 +++++++++++-------- ios/ApiClient/ApiClient.mm | 4 +- ios/NetworkClient.swift | 10 ++++- ios/SessionAttributes/SessionAttributes.swift | 2 +- ios/patches/apply_patches.rb | 7 +--- package-lock.json | 4 +- package.json | 2 +- src/APIClient/NativeApiClient.ts | 1 + src/schemas.tsx | 1 + src/types/APIClient.ts | 1 + 15 files changed, 91 insertions(+), 54 deletions(-) diff --git a/android/src/main/java/com/mattermost/networkclient/ApiClientModuleImpl.kt b/android/src/main/java/com/mattermost/networkclient/ApiClientModuleImpl.kt index e0b3940d1..9647c8491 100644 --- a/android/src/main/java/com/mattermost/networkclient/ApiClientModuleImpl.kt +++ b/android/src/main/java/com/mattermost/networkclient/ApiClientModuleImpl.kt @@ -133,6 +133,7 @@ class ApiClientModuleImpl(appContext: Context) { init { setCtx(appContext) + SessionAttributesEngine.init(appContext) migrateSharedPreferences(appContext) setCookieJar(appContext) } @@ -362,31 +363,31 @@ class ApiClientModuleImpl(appContext: Context) { } fun setSessionAttributesEnabled(serverUrl: String, enabled: Boolean) { - SessionAttributesEngine.getInstance(context).setEnabled(serverUrl, enabled) + SessionAttributesEngine.setEnabled(serverUrl, enabled) } fun removeSessionAttributesServer(serverUrl: String) { - SessionAttributesEngine.getInstance(context).removeServer(serverUrl) + SessionAttributesEngine.removeServer(serverUrl) } fun setSessionAttributesManifest(serverUrl: String, manifest: String) { - SessionAttributesEngine.getInstance(context).setManifest(serverUrl, manifest) + SessionAttributesEngine.setManifest(serverUrl, manifest) } fun upsertSessionAttributesField(serverUrl: String, field: String) { - SessionAttributesEngine.getInstance(context).upsertManifestField(serverUrl, field) + SessionAttributesEngine.upsertManifestField(serverUrl, field) } fun removeSessionAttributesField(serverUrl: String, name: String) { - SessionAttributesEngine.getInstance(context).removeManifestField(serverUrl, name) + SessionAttributesEngine.removeManifestField(serverUrl, name) } fun setSessionAttributesStableValues(values: String) { - SessionAttributesEngine.getInstance(context).setStableValues(values) + SessionAttributesEngine.setStableValues(values) } fun getSessionAttributesHeader(serverUrl: String): String? { - return SessionAttributesEngine.getInstance(context).getOutboundHeader(serverUrl) + return SessionAttributesEngine.getOutboundHeader(serverUrl) } // Methods to use with native implementations diff --git a/android/src/main/java/com/mattermost/networkclient/NetworkClient.kt b/android/src/main/java/com/mattermost/networkclient/NetworkClient.kt index 294d7cc67..0a40e4bfb 100644 --- a/android/src/main/java/com/mattermost/networkclient/NetworkClient.kt +++ b/android/src/main/java/com/mattermost/networkclient/NetworkClient.kt @@ -161,10 +161,13 @@ internal class NetworkClient(private val context: Context, private val baseUrl: builder.addInterceptor(bearerTokenInterceptor) } - // Runs after BearerTokenInterceptor so the Authorization header is present when the - // guard is evaluated. Also covers adaptRCTRequest() since RCT requests are executed - // through this same client's okHttpClient. - builder.addInterceptor(SessionAttributesInterceptor(context, baseUrlString)) + val sessionAttributesInterceptor = getSessionAttributesInterceptor(options) + if (sessionAttributesInterceptor != null) { + // Added after BearerTokenInterceptor so the Authorization header is present when the + // guard is evaluated. Also covers adaptRCTRequest() since RCT requests are executed + // through this same client's okHttpClient. + builder.addInterceptor(sessionAttributesInterceptor) + } applyClientSslConfiguration(options) configureSsl() @@ -501,6 +504,19 @@ internal class NetworkClient(private val context: Context, private val baseUrl: return null } + private fun getSessionAttributesInterceptor(options: ReadableMap?): SessionAttributesInterceptor? { + if (options != null && options.hasKey("requestAdapterConfiguration")) { + val requestAdapterConfiguration = options.getMap("requestAdapterConfiguration")!! + if (requestAdapterConfiguration.hasKey("enableSessionAttributes") && + requestAdapterConfiguration.getBoolean("enableSessionAttributes") + ) { + return SessionAttributesInterceptor(baseUrlString) + } + } + + return null + } + @SuppressLint("CustomX509TrustManager") private fun getTrustManager(defaultTrustManager: X509TrustManager): X509TrustManager { return object : X509TrustManager { diff --git a/android/src/main/java/com/mattermost/networkclient/interceptors/SessionAttributesInterceptor.kt b/android/src/main/java/com/mattermost/networkclient/interceptors/SessionAttributesInterceptor.kt index 7a0992fed..55473410b 100644 --- a/android/src/main/java/com/mattermost/networkclient/interceptors/SessionAttributesInterceptor.kt +++ b/android/src/main/java/com/mattermost/networkclient/interceptors/SessionAttributesInterceptor.kt @@ -1,16 +1,12 @@ package com.mattermost.networkclient.interceptors -import android.content.Context -import com.mattermost.networkclient.sessionattributes.SessionAttributes import com.mattermost.networkclient.sessionattributes.SessionAttributesConstants +import com.mattermost.networkclient.sessionattributes.SessionAttributesEngine import okhttp3.Interceptor import okhttp3.Response import java.io.IOException -class SessionAttributesInterceptor( - private val context: Context, - private val serverUrl: String, -) : Interceptor { +class SessionAttributesInterceptor(private val serverUrl: String) : Interceptor { @Throws(IOException::class) override fun intercept(chain: Interceptor.Chain): Response { val request = chain.request() @@ -22,7 +18,7 @@ class SessionAttributesInterceptor( return chain.proceed(request) } - val header = SessionAttributes.getOutboundHeader(context, serverUrl) + val header = SessionAttributesEngine.getOutboundHeader(serverUrl) ?: return chain.proceed(request) val newRequest = request.newBuilder() diff --git a/android/src/main/java/com/mattermost/networkclient/sessionattributes/SessionAttributes.kt b/android/src/main/java/com/mattermost/networkclient/sessionattributes/SessionAttributes.kt index 44977c2b7..8c9b3c63a 100644 --- a/android/src/main/java/com/mattermost/networkclient/sessionattributes/SessionAttributes.kt +++ b/android/src/main/java/com/mattermost/networkclient/sessionattributes/SessionAttributes.kt @@ -6,10 +6,19 @@ import android.content.Context * React-free entry point for native code outside this library (e.g. app * background handlers, standalone OkHttp usage) to resolve the outbound * X-MM-Session-Attributes header for a server. + * + * Callers that reach this outside the React module (e.g. WorkManager jobs) must + * call [init] from their Application.onCreate, which runs before any component + * in the process. */ object SessionAttributes { @JvmStatic - fun getOutboundHeader(context: Context, serverUrl: String): String? { - return SessionAttributesEngine.getInstance(context).getOutboundHeader(serverUrl) + fun init(context: Context) { + SessionAttributesEngine.init(context) + } + + @JvmStatic + fun getOutboundHeader(serverUrl: String): String? { + return SessionAttributesEngine.getOutboundHeader(serverUrl) } } diff --git a/android/src/main/java/com/mattermost/networkclient/sessionattributes/SessionAttributesCollector.kt b/android/src/main/java/com/mattermost/networkclient/sessionattributes/SessionAttributesCollector.kt index b1f307736..86e22a174 100644 --- a/android/src/main/java/com/mattermost/networkclient/sessionattributes/SessionAttributesCollector.kt +++ b/android/src/main/java/com/mattermost/networkclient/sessionattributes/SessionAttributesCollector.kt @@ -8,6 +8,7 @@ import android.net.NetworkCapabilities import android.net.Uri import android.net.wifi.WifiManager import android.os.Build +import android.util.Log import java.net.Inet4Address import java.net.NetworkInterface import java.util.concurrent.ConcurrentHashMap @@ -44,7 +45,8 @@ class SessionAttributesCollector( return fqdnCache.computeIfAbsent(serverUrl) { url -> try { Uri.parse(url).host ?: "" - } catch (_: Exception) { + } catch (e: Exception) { + Log.w("NetworkClient", "Failed to resolve server FQDN: ${e.message}") "" } } @@ -117,7 +119,8 @@ class SessionAttributesCollector( } } "" - } catch (_: Exception) { + } catch (e: Exception) { + Log.w("NetworkClient", "Failed to resolve client IP address: ${e.message}") "" } } @@ -133,7 +136,8 @@ class SessionAttributesCollector( } else { ssid } - } catch (_: Exception) { + } catch (e: Exception) { + Log.w("NetworkClient", "Failed to resolve SSID: ${e.message}") "" } } 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 ee9262fff..93b2c7ca0 100644 --- a/android/src/main/java/com/mattermost/networkclient/sessionattributes/SessionAttributesEngine.kt +++ b/android/src/main/java/com/mattermost/networkclient/sessionattributes/SessionAttributesEngine.kt @@ -2,13 +2,21 @@ package com.mattermost.networkclient.sessionattributes import android.content.Context import android.util.Base64 +import android.util.Log import org.json.JSONArray import org.json.JSONObject -class SessionAttributesEngine private constructor(context: Context) { - private val appContext = context.applicationContext - private val store = SessionAttributesStore(appContext) - private val collector = SessionAttributesCollector(appContext, store) +object SessionAttributesEngine { + private lateinit var context: Context + + private val store: SessionAttributesStore by lazy { SessionAttributesStore(context) } + private val collector: SessionAttributesCollector by lazy { SessionAttributesCollector(context, store) } + + fun init(appContext: Context) { + if (!::context.isInitialized) { + context = appContext.applicationContext + } + } fun setEnabled(serverUrl: String, enabled: Boolean) { store.setEnabled(serverUrl, enabled) @@ -21,7 +29,8 @@ class SessionAttributesEngine private constructor(context: Context) { fun setManifest(serverUrl: String, manifestJson: String) { val manifest = try { JSONArray(manifestJson) - } catch (_: Exception) { + } catch (e: Exception) { + Log.w("NetworkClient", "Discarding malformed manifest for $serverUrl: ${e.message}") null } val fields = mutableListOf() @@ -40,7 +49,8 @@ class SessionAttributesEngine private constructor(context: Context) { fun upsertManifestField(serverUrl: String, fieldJson: String) { val field = try { JSONObject(fieldJson) - } catch (_: Exception) { + } catch (e: Exception) { + Log.w("NetworkClient", "Ignoring malformed manifest field for $serverUrl: ${e.message}") return } SAField.fromJson(field)?.let { store.upsertField(serverUrl, it) } @@ -53,7 +63,8 @@ class SessionAttributesEngine private constructor(context: Context) { fun setStableValues(valuesJson: String) { val json = try { JSONObject(valuesJson) - } catch (_: Exception) { + } catch (e: Exception) { + Log.w("NetworkClient", "Ignoring malformed stable values: ${e.message}") return } val values = mutableMapOf() @@ -64,6 +75,11 @@ class SessionAttributesEngine private constructor(context: Context) { } fun getOutboundHeader(serverUrl: String): String? { + if (!::context.isInitialized) { + Log.w("NetworkClient", "Session attributes requested before init") + return null + } + val state = store.loadState(serverUrl) ?: return null if (!state.enabled || state.manifest.isEmpty()) { return null @@ -97,15 +113,4 @@ class SessionAttributesEngine private constructor(context: Context) { return Base64.encodeToString(payload.toString().toByteArray(), Base64.NO_WRAP) } - - companion object { - @Volatile - private var instance: SessionAttributesEngine? = null - - fun getInstance(context: Context): SessionAttributesEngine { - return instance ?: synchronized(this) { - instance ?: SessionAttributesEngine(context).also { instance = it } - } - } - } } diff --git a/ios/ApiClient/ApiClient.mm b/ios/ApiClient/ApiClient.mm index 0572f8b1e..9132e6e38 100644 --- a/ios/ApiClient/ApiClient.mm +++ b/ios/ApiClient/ApiClient.mm @@ -301,7 +301,9 @@ -(NSDictionary *) convertClientConfigurationToDictionary: (JS::NativeApiClient:: if (config.requestAdapterConfiguration().has_value()) { NSMutableDictionary *adapterDictionary = [[NSMutableDictionary alloc] init]; - adapterDictionary[@"bearerAuthTokenResponseHeader"] = config.requestAdapterConfiguration().value().bearerAuthTokenResponseHeader(); + JS::NativeApiClient::RequestAdapterConfiguration adapter = config.requestAdapterConfiguration().value(); + adapterDictionary[@"bearerAuthTokenResponseHeader"] = adapter.bearerAuthTokenResponseHeader(); + adapterDictionary[@"enableSessionAttributes"] = [self processBooleanValue:adapter.enableSessionAttributes()]; dict[@"requestAdapterConfiguration"] = adapterDictionary; } diff --git a/ios/NetworkClient.swift b/ios/NetworkClient.swift index da274b27d..0e59612cc 100644 --- a/ios/NetworkClient.swift +++ b/ios/NetworkClient.swift @@ -294,8 +294,14 @@ extension NetworkClient { adapters.append(BearerAuthenticationAdapter()) } - // Must run after BearerAuthenticationAdapter so the Authorization header is present. - adapters.append(SessionAttributesAdapter()) + if options["requestAdapterConfiguration"]["enableSessionAttributes"].boolValue { + // Added after BearerAuthenticationAdapter so the Authorization header is present. + adapters.append(SessionAttributesAdapter()) + } + + if (adapters.isEmpty) { + return Interceptor(retriers: retriers) + } return Interceptor(adapters: adapters, retriers: retriers) } diff --git a/ios/SessionAttributes/SessionAttributes.swift b/ios/SessionAttributes/SessionAttributes.swift index bd31fb48c..dfb48b236 100644 --- a/ios/SessionAttributes/SessionAttributes.swift +++ b/ios/SessionAttributes/SessionAttributes.swift @@ -4,7 +4,7 @@ import Foundation /// React-free entry point for native code outside this library (e.g. Gekidou, -/// app extensions, standalone URLSession/OkHttp usage) to resolve the outbound +/// app extensions, standalone URLSession usage) to resolve the outbound /// `X-MM-Session-Attributes` header for a server. @objc public class SessionAttributes: NSObject { @objc public static func getOutboundHeader(_ serverUrl: String) -> String? { diff --git a/ios/patches/apply_patches.rb b/ios/patches/apply_patches.rb index 652ce1c1d..4663f3788 100644 --- a/ios/patches/apply_patches.rb +++ b/ios/patches/apply_patches.rb @@ -68,12 +68,7 @@ def patch_additions_present?(file, repo_root, directory_arg) def apply_patch(file) repo_root = `git rev-parse --show-toplevel`.strip - pods_dir = Dir.glob(Pathname(repo_root).join("**/**/Pods")).first - unless pods_dir - Pod::UI.puts "Skipping #{file} (Pods directory not found yet)" - return - end - directory_arg = pods_dir.sub("#{repo_root}/", "") + directory_arg = Dir.glob(Pathname(repo_root).join("**/**/Pods")).first.sub("#{repo_root}/", "") Dir.chdir(repo_root) { base_args = "'#{file}' --directory='#{directory_arg}' -p2 2> /dev/null" diff --git a/package-lock.json b/package-lock.json index d0c92742c..deff72bb0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@mattermost/react-native-network-client", - "version": "1.11.0", + "version": "1.10.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@mattermost/react-native-network-client", - "version": "1.11.0", + "version": "1.10.3", "license": "MIT", "dependencies": { "validator": "13.15.35", diff --git a/package.json b/package.json index 3e9a7008e..87345b8cc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@mattermost/react-native-network-client", - "version": "1.11.0", + "version": "1.10.3", "description": "Configurable network clients for React Native. Uses Alamofire for iOS and OkHttp3 for Android.", "main": "lib/commonjs/index", "module": "lib/module/index", diff --git a/src/APIClient/NativeApiClient.ts b/src/APIClient/NativeApiClient.ts index a48f14682..6f13d6855 100644 --- a/src/APIClient/NativeApiClient.ts +++ b/src/APIClient/NativeApiClient.ts @@ -81,6 +81,7 @@ export type SessionConfiguration = { export type RequestAdapterConfiguration = { bearerAuthTokenResponseHeader?: string; + enableSessionAttributes?: boolean; }; export type ClientP12Configuration = Readonly<{ diff --git a/src/schemas.tsx b/src/schemas.tsx index 955ab7f23..ef3bdaed4 100644 --- a/src/schemas.tsx +++ b/src/schemas.tsx @@ -32,6 +32,7 @@ const RetryPolicyConfigurationSchema = z.object({ const RequestAdapterConfigurationSchema = z.object({ bearerAuthTokenResponseHeader: z.string().optional(), + enableSessionAttributes: z.boolean().optional(), }); const ClientP12ConfigurationSchema = z.object({ diff --git a/src/types/APIClient.ts b/src/types/APIClient.ts index b8b6533c7..51e1810e1 100644 --- a/src/types/APIClient.ts +++ b/src/types/APIClient.ts @@ -142,6 +142,7 @@ export type RetryPolicyConfiguration = { export type RequestAdapterConfiguration = { bearerAuthTokenResponseHeader?: string; + enableSessionAttributes?: boolean; }; export type APIClientConfiguration = { From 9734d70a6af6c823ad635fa3661faf3ad7d09a01 Mon Sep 17 00:00:00 2001 From: Devin Binnie Date: Mon, 10 Aug 2026 14:01:21 -0400 Subject: [PATCH 3/3] Update the Session Attributes store to include caching. --- .../SessionAttributesStore.kt | 225 ++++++++++++------ .../networkclient/SessionAttributesTest.kt | 90 +++++++ 2 files changed, 243 insertions(+), 72 deletions(-) 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 a80054dd3..57ed2bed1 100644 --- a/android/src/main/java/com/mattermost/networkclient/sessionattributes/SessionAttributesStore.kt +++ b/android/src/main/java/com/mattermost/networkclient/sessionattributes/SessionAttributesStore.kt @@ -1,6 +1,7 @@ package com.mattermost.networkclient.sessionattributes import android.content.Context +import android.util.Log import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.edit @@ -50,6 +51,14 @@ data class ServerSessionAttributesState( var manifest: MutableList, var lastSentAt: MutableMap, ) { + fun snapshot(): ServerSessionAttributesState { + return ServerSessionAttributesState( + enabled = enabled, + manifest = manifest.toMutableList(), + lastSentAt = lastSentAt.toMutableMap(), + ) + } + fun toJson(): JSONObject { val manifestArray = JSONArray() manifest.forEach { manifestArray.put(it.toJson()) } @@ -93,7 +102,17 @@ private val Context.sessionAttributesDataStore: DataStore by prefer class SessionAttributesStore(context: Context) { private val appContext = context.applicationContext - private val lock = Any() + + // Guards the in-memory caches only, so the request path never blocks on + // DataStore reads or KeyStore crypto while the lock is held. + private val cacheLock = Any() + private val stateCache = mutableMapOf() + private var stableValues: Map? = null + + // 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 + // DataStore write cannot deadlock. + private val persistLock = Any() fun serverKey(serverUrl: String): String { val normalized = serverUrl.trimEnd('/') @@ -101,117 +120,179 @@ class SessionAttributesStore(context: Context) { return digest.joinToString("") { "%02x".format(it) } } - private fun stateAlias(serverUrl: String): String { - return "${SessionAttributesConstants.STORE_PREFIX}${serverKey(serverUrl)}-${SessionAttributesConstants.STATE_ALIAS_SUFFIX}" + fun loadState(serverUrl: String): ServerSessionAttributesState? { + val state = cachedState(serverUrl) ?: return null + return synchronized(cacheLock) { state.snapshot() } } - private fun readValue(alias: String): String? { - val encrypted = runBlocking { - appContext.sessionAttributesDataStore.data.first()[stringPreferencesKey(alias)] - } ?: return null - return try { - KeyStoreHelper.decryptData(encrypted) - } catch (_: Exception) { - null + fun saveState(serverUrl: String, state: ServerSessionAttributesState) { + val json = synchronized(cacheLock) { + val stored = state.snapshot() + stateCache[serverUrl] = stored + stored.toJson().toString() } + persist(stateAlias(serverUrl), json) } - private fun writeValue(alias: String, value: String) { - val encrypted = KeyStoreHelper.encryptData(value) - runBlocking { - appContext.sessionAttributesDataStore.edit { preferences -> - preferences[stringPreferencesKey(alias)] = encrypted - } + fun removeState(serverUrl: String) { + synchronized(cacheLock) { + stateCache.remove(serverUrl) } + delete(stateAlias(serverUrl)) } - private fun deleteValue(alias: String) { - runBlocking { - appContext.sessionAttributesDataStore.edit { preferences -> - preferences.remove(stringPreferencesKey(alias)) + fun setEnabled(serverUrl: String, enabled: Boolean) { + val existing = cachedState(serverUrl) + val json = synchronized(cacheLock) { + val state = existing ?: ServerSessionAttributesState(false, mutableListOf(), mutableMapOf()) + state.enabled = enabled + if (!enabled) { + state.manifest.clear() + state.lastSentAt.clear() } + stateCache[serverUrl] = state + state.toJson().toString() } + persist(stateAlias(serverUrl), json) } - fun loadState(serverUrl: String): ServerSessionAttributesState? = synchronized(lock) { - val raw = readValue(stateAlias(serverUrl)) ?: return null - return try { - ServerSessionAttributesState.fromJson(JSONObject(raw)) - } catch (_: Exception) { - null + fun setManifest(serverUrl: String, manifest: List) { + val existing = cachedState(serverUrl) + val json = synchronized(cacheLock) { + val state = existing ?: ServerSessionAttributesState(true, mutableListOf(), mutableMapOf()) + state.enabled = true + state.manifest = manifest.toMutableList() + state.lastSentAt.clear() + stateCache[serverUrl] = state + state.toJson().toString() } + persist(stateAlias(serverUrl), json) } - fun saveState(serverUrl: String, state: ServerSessionAttributesState) = synchronized(lock) { - writeValue(stateAlias(serverUrl), state.toJson().toString()) + fun upsertField(serverUrl: String, field: SAField) { + val state = cachedState(serverUrl)?.takeIf { it.enabled } ?: return + val json = synchronized(cacheLock) { + val index = state.manifest.indexOfFirst { it.name == field.name } + if (index == -1) { + state.manifest.add(field) + } else { + state.manifest[index] = field + } + state.lastSentAt.remove(field.name) + state.toJson().toString() + } + persist(stateAlias(serverUrl), json) } - fun removeState(serverUrl: String) = synchronized(lock) { - deleteValue(stateAlias(serverUrl)) + fun removeField(serverUrl: String, name: String) { + val state = cachedState(serverUrl)?.takeIf { it.enabled } ?: return + val json = synchronized(cacheLock) { + state.manifest.removeAll { it.name == name } + state.lastSentAt.remove(name) + state.toJson().toString() + } + persist(stateAlias(serverUrl), json) } - fun setEnabled(serverUrl: String, enabled: Boolean) = synchronized(lock) { - val state = loadStateLocked(serverUrl) ?: ServerSessionAttributesState(false, mutableListOf(), mutableMapOf()) - state.enabled = enabled - if (!enabled) { - state.manifest.clear() - state.lastSentAt.clear() + fun setStableValues(values: Map) { + val json = JSONObject() + values.forEach { (key, value) -> json.put(key, value) } + synchronized(cacheLock) { + stableValues = values.toMap() } - writeValue(stateAlias(serverUrl), state.toJson().toString()) + persist(SessionAttributesConstants.STABLE_VALUES_ALIAS, json.toString()) } - fun setManifest(serverUrl: String, manifest: List) = synchronized(lock) { - val state = loadStateLocked(serverUrl) ?: ServerSessionAttributesState(true, mutableListOf(), mutableMapOf()) - state.enabled = true - state.manifest = manifest.toMutableList() - state.lastSentAt.clear() - writeValue(stateAlias(serverUrl), state.toJson().toString()) - } + fun getStableValue(name: String): String? { + synchronized(cacheLock) { + stableValues?.let { values -> return values[name]?.takeIf { it.isNotEmpty() } } + } - fun upsertField(serverUrl: String, field: SAField) = synchronized(lock) { - val state = loadStateLocked(serverUrl)?.takeIf { it.enabled } ?: return - val index = state.manifest.indexOfFirst { it.name == field.name } - if (index == -1) { - state.manifest.add(field) - } else { - state.manifest[index] = field + val restored = readStableValues() + synchronized(cacheLock) { + val values = stableValues ?: restored.also { stableValues = it } + return values[name]?.takeIf { it.isNotEmpty() } } - state.lastSentAt.remove(field.name) - writeValue(stateAlias(serverUrl), state.toJson().toString()) } - fun removeField(serverUrl: String, name: String) = synchronized(lock) { - val state = loadStateLocked(serverUrl)?.takeIf { it.enabled } ?: return - state.manifest.removeAll { it.name == name } - state.lastSentAt.remove(name) - writeValue(stateAlias(serverUrl), state.toJson().toString()) + private fun stateAlias(serverUrl: String): String { + return "${SessionAttributesConstants.STORE_PREFIX}${serverKey(serverUrl)}-${SessionAttributesConstants.STATE_ALIAS_SUFFIX}" } - fun setStableValues(values: Map) = synchronized(lock) { - val json = JSONObject() - values.forEach { (key, value) -> json.put(key, value) } - writeValue(SessionAttributesConstants.STABLE_VALUES_ALIAS, json.toString()) + /** + * Returns the cached state for [serverUrl], restoring it from disk on the first + * access. The returned instance is the cached one, so callers that mutate it must + * do so while holding [cacheLock]. + */ + private fun cachedState(serverUrl: String): ServerSessionAttributesState? { + synchronized(cacheLock) { + stateCache[serverUrl]?.let { return it } + } + + val restored = readState(serverUrl) ?: return null + synchronized(cacheLock) { + return stateCache.getOrPut(serverUrl) { restored } + } } - fun getStableValue(name: String): String? = synchronized(lock) { - val raw = readValue(SessionAttributesConstants.STABLE_VALUES_ALIAS) ?: return null + private fun readState(serverUrl: String): ServerSessionAttributesState? { + val raw = readValue(stateAlias(serverUrl)) ?: return null return try { - val value = JSONObject(raw).optString(name, "") - if (value.isEmpty()) null else value - } catch (_: Exception) { + ServerSessionAttributesState.fromJson(JSONObject(raw)) + } catch (e: Exception) { + Log.w("NetworkClient", "Discarding unreadable stored state: ${e.message}") null } } - private fun loadStateLocked(serverUrl: String): ServerSessionAttributesState? { - val raw = readValue(stateAlias(serverUrl)) ?: return null + private fun readStableValues(): Map { + val raw = readValue(SessionAttributesConstants.STABLE_VALUES_ALIAS) ?: return emptyMap() return try { - ServerSessionAttributesState.fromJson(JSONObject(raw)) - } catch (_: Exception) { + val json = JSONObject(raw) + json.keys().asSequence().associateWith { json.optString(it, "") } + } catch (e: Exception) { + Log.w("NetworkClient", "Discarding unreadable stable values: ${e.message}") + emptyMap() + } + } + + private fun readValue(alias: String): String? { + return try { + val encrypted = runBlocking { + appContext.sessionAttributesDataStore.data.first()[stringPreferencesKey(alias)] + } ?: return null + KeyStoreHelper.decryptData(encrypted) + } catch (e: Exception) { + Log.w("NetworkClient", "Failed to read $alias: ${e.message}") null } } + private fun persist(alias: String, value: String) = synchronized(persistLock) { + try { + val encrypted = KeyStoreHelper.encryptData(value) + runBlocking { + appContext.sessionAttributesDataStore.edit { preferences -> + preferences[stringPreferencesKey(alias)] = encrypted + } + } + } catch (e: Exception) { + Log.w("NetworkClient", "Failed to persist $alias: ${e.message}") + } + } + + private fun delete(alias: String) = synchronized(persistLock) { + try { + runBlocking { + appContext.sessionAttributesDataStore.edit { preferences -> + preferences.remove(stringPreferencesKey(alias)) + } + } + } catch (e: Exception) { + Log.w("NetworkClient", "Failed to remove $alias: ${e.message}") + } + } + companion object { const val DATASTORE_NAME = "SessionAttributesDataStore" } 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 acb98ee9d..7784702a0 100644 --- a/test-runner/src/test/kotlin/com/mattermost/networkclient/SessionAttributesTest.kt +++ b/test-runner/src/test/kotlin/com/mattermost/networkclient/SessionAttributesTest.kt @@ -65,6 +65,35 @@ class SessionAttributesTest { } } + /** + * Read-through cache matching SessionAttributesStore: the encrypted backing store is + * only read on the first access for a server, writes update the cache in place, and + * removal evicts it. + */ + private class InlineStateCache(private val backingStore: MutableMap) { + private val cache = mutableMapOf() + var backingReads = 0 + private set + + fun load(serverUrl: String): String? { + cache[serverUrl]?.let { return it } + backingReads++ + val restored = backingStore[serverUrl] ?: return null + cache[serverUrl] = restored + return restored + } + + fun save(serverUrl: String, state: String) { + cache[serverUrl] = state + backingStore[serverUrl] = state + } + + fun remove(serverUrl: String) { + cache.remove(serverUrl) + backingStore.remove(serverUrl) + } + } + // --------------------------------------------------------------------------- // Engine TTL logic // --------------------------------------------------------------------------- @@ -213,4 +242,65 @@ class SessionAttributesTest { Assert.assertNull(recorded.getHeader("X-MM-Session-Attributes")) } } + + // --------------------------------------------------------------------------- + // Store state cache + // --------------------------------------------------------------------------- + + @Test + fun cache_readsBackingStoreOnlyOncePerServer() { + val cache = InlineStateCache(mutableMapOf("https://server.one" to "state-one")) + + Assert.assertEquals("state-one", cache.load("https://server.one")) + Assert.assertEquals("state-one", cache.load("https://server.one")) + + Assert.assertEquals(1, cache.backingReads) + } + + @Test + fun cache_readsBackingStoreAgainWhenServerHasNoState() { + val cache = InlineStateCache(mutableMapOf()) + + Assert.assertNull(cache.load("https://server.one")) + Assert.assertNull(cache.load("https://server.one")) + + Assert.assertEquals(2, cache.backingReads) + } + + @Test + fun cache_writeServesSubsequentReadsWithoutBackingStore() { + val cache = InlineStateCache(mutableMapOf()) + + cache.save("https://server.one", "state-one") + + Assert.assertEquals("state-one", cache.load("https://server.one")) + Assert.assertEquals(0, cache.backingReads) + } + + @Test + fun cache_removeEvictsCachedState() { + val backingStore = mutableMapOf("https://server.one" to "state-one") + val cache = InlineStateCache(backingStore) + + Assert.assertEquals("state-one", cache.load("https://server.one")) + cache.remove("https://server.one") + + Assert.assertNull(cache.load("https://server.one")) + Assert.assertTrue(backingStore.isEmpty()) + } + + @Test + fun cache_keepsServersIsolated() { + val cache = InlineStateCache( + mutableMapOf( + "https://server.one" to "state-one", + "https://server.two" to "state-two", + ), + ) + + Assert.assertEquals("state-one", cache.load("https://server.one")) + cache.remove("https://server.one") + + Assert.assertEquals("state-two", cache.load("https://server.two")) + } }