From 902ffe9445317ad8e042773a46ab0719310218c8 Mon Sep 17 00:00:00 2001 From: Mohamad Jaara Date: Mon, 2 Feb 2026 15:04:10 +0100 Subject: [PATCH] feat: implement Android user context provider and managed config parser for MDM support --- .../android/di/ManagedConfigurationsModule.kt | 25 +- .../android/emm/AndroidUserContextProvider.kt | 59 ++++ .../wire/android/emm/ManagedConfigParser.kt | 157 +++++++++ .../emm/ManagedConfigurationsManager.kt | 39 +-- .../emm/AndroidUserContextProviderTest.kt | 72 +++++ .../android/emm/ManagedConfigParserTest.kt | 301 ++++++++++++++++++ .../emm/ManagedConfigurationsManagerTest.kt | 203 +++++++++++- 7 files changed, 831 insertions(+), 25 deletions(-) create mode 100644 app/src/main/kotlin/com/wire/android/emm/AndroidUserContextProvider.kt create mode 100644 app/src/main/kotlin/com/wire/android/emm/ManagedConfigParser.kt create mode 100644 app/src/test/kotlin/com/wire/android/emm/AndroidUserContextProviderTest.kt create mode 100644 app/src/test/kotlin/com/wire/android/emm/ManagedConfigParserTest.kt diff --git a/app/src/main/kotlin/com/wire/android/di/ManagedConfigurationsModule.kt b/app/src/main/kotlin/com/wire/android/di/ManagedConfigurationsModule.kt index 47b9517bf75..0044a11949a 100644 --- a/app/src/main/kotlin/com/wire/android/di/ManagedConfigurationsModule.kt +++ b/app/src/main/kotlin/com/wire/android/di/ManagedConfigurationsModule.kt @@ -20,6 +20,10 @@ package com.wire.android.di import android.content.Context import com.wire.android.BuildConfig import com.wire.android.config.ServerConfigProvider +import com.wire.android.emm.AndroidUserContextProvider +import com.wire.android.emm.AndroidUserContextProviderImpl +import com.wire.android.emm.ManagedConfigParser +import com.wire.android.emm.ManagedConfigParserImpl import com.wire.android.emm.ManagedConfigurationsManager import com.wire.android.emm.ManagedConfigurationsManagerImpl import com.wire.android.util.EMPTY @@ -41,14 +45,31 @@ class ManagedConfigurationsModule { @Singleton fun provideServerConfigProvider(): ServerConfigProvider = ServerConfigProvider() + @Provides + @Singleton + fun provideAndroidUserContextProvider(): AndroidUserContextProvider = + AndroidUserContextProviderImpl() + + @Provides + @Singleton + fun provideManagedConfigParser( + userContextProvider: AndroidUserContextProvider + ): ManagedConfigParser = ManagedConfigParserImpl(userContextProvider) + @Provides @Singleton fun provideManagedConfigurationsRepository( @ApplicationContext context: Context, dispatcherProvider: DispatcherProvider, - serverConfigProvider: ServerConfigProvider + serverConfigProvider: ServerConfigProvider, + configParser: ManagedConfigParser ): ManagedConfigurationsManager { - return ManagedConfigurationsManagerImpl(context, dispatcherProvider, serverConfigProvider) + return ManagedConfigurationsManagerImpl( + context, + dispatcherProvider, + serverConfigProvider, + configParser + ) } @Provides diff --git a/app/src/main/kotlin/com/wire/android/emm/AndroidUserContextProvider.kt b/app/src/main/kotlin/com/wire/android/emm/AndroidUserContextProvider.kt new file mode 100644 index 00000000000..9adde577414 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/emm/AndroidUserContextProvider.kt @@ -0,0 +1,59 @@ +/* + * Wire + * Copyright (C) 2025 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.emm + +import android.os.Process + +/** + * Provides the current Android user context for multi-app MDM configurations. + * + * On Android, each user (including work profiles) has a unique user ID. + * The user ID is calculated as UID / 100000, where UID is the process UID. + * - User 0: Main user (UID 0-99999) + * - User 10: Work profile or secondary user (UID 1000000-1099999) + */ +interface AndroidUserContextProvider { + /** + * Returns the current Android user ID. + * This is calculated as Process.myUid() / 100000. + * + * @return The user ID (e.g., 0 for main user, 10 for work profile) + */ + fun getCurrentAndroidUserId(): Int + + /** + * Returns the current user ID as a string key for configuration lookup. + * + * @return The user ID as a string (e.g., "0", "10") + */ + fun getCurrentUserIdKey(): String + + companion object { + const val DEFAULT_KEY = "default" + internal const val UID_DIVISOR = 100_000 + } +} + +internal class AndroidUserContextProviderImpl : AndroidUserContextProvider { + + override fun getCurrentAndroidUserId(): Int = + Process.myUid() / AndroidUserContextProvider.UID_DIVISOR + + override fun getCurrentUserIdKey(): String = + getCurrentAndroidUserId().toString() +} diff --git a/app/src/main/kotlin/com/wire/android/emm/ManagedConfigParser.kt b/app/src/main/kotlin/com/wire/android/emm/ManagedConfigParser.kt new file mode 100644 index 00000000000..caf7418143f --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/emm/ManagedConfigParser.kt @@ -0,0 +1,157 @@ +/* + * Wire + * Copyright (C) 2025 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.emm + +import com.wire.android.appLogger +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.decodeFromJsonElement +import kotlinx.serialization.json.jsonObject + +/** + * Parser for MDM managed configurations that supports both unified and context-mapped formats. + * + * **Unified Format (backward compatible):** + * ```json + * { + * "title": "Enterprise Server", + * "endpoints": { ... } + * } + * ``` + * + * **Context-Mapped Format (multi-app support):** + * ```json + * { + * "0": { "title": "Secure Server", "endpoints": { ... } }, + * "default": { "title": "General Server", "endpoints": { ... } } + * } + * ``` + * + * The parser automatically detects the format and resolves the appropriate configuration + * based on the current Android user context. + */ +interface ManagedConfigParser { + /** + * Parses server configuration from raw JSON string. + * + * @param rawJson The raw JSON string from MDM restrictions + * @return Parsed [ManagedServerConfig] or null if parsing fails or no config found + * @throws InvalidManagedConfig if JSON is malformed + */ + fun parseServerConfig(rawJson: String): ManagedServerConfig? + + /** + * Parses SSO code configuration from raw JSON string. + * + * @param rawJson The raw JSON string from MDM restrictions + * @return Parsed [ManagedSSOCodeConfig] or null if parsing fails or no config found + * @throws InvalidManagedConfig if JSON is malformed + */ + fun parseSSOCodeConfig(rawJson: String): ManagedSSOCodeConfig? +} + +internal class ManagedConfigParserImpl( + private val userContextProvider: AndroidUserContextProvider +) : ManagedConfigParser { + + private val json: Json = Json { ignoreUnknownKeys = true } + private val logger = appLogger.withTextTag(TAG) + + override fun parseServerConfig(rawJson: String): ManagedServerConfig? { + return parseConfig( + rawJson = rawJson, + configType = "server", + isUnifiedFormat = ::isUnifiedServerFormat, + parseUnified = { json.decodeFromString(rawJson) }, + parseFromObject = { json.decodeFromJsonElement(it) } + ) + } + + override fun parseSSOCodeConfig(rawJson: String): ManagedSSOCodeConfig? { + return parseConfig( + rawJson = rawJson, + configType = "SSO", + isUnifiedFormat = ::isUnifiedSSOFormat, + parseUnified = { json.decodeFromString(rawJson) }, + parseFromObject = { json.decodeFromJsonElement(it) } + ) + } + + @Suppress("TooGenericExceptionCaught") + private inline fun parseConfig( + rawJson: String, + configType: String, + isUnifiedFormat: (JsonObject) -> Boolean, + parseUnified: () -> T, + parseFromObject: (JsonObject) -> T + ): T? { + return try { + val jsonObject = json.parseToJsonElement(rawJson).jsonObject + + if (isUnifiedFormat(jsonObject)) { + logger.i("Detected unified $configType config format") + parseUnified() + } else { + logger.i("Detected context-mapped $configType config format") + resolveContextMappedConfig(jsonObject, parseFromObject) + } + } catch (e: Exception) { + throw InvalidManagedConfig("Failed to parse managed $configType config: ${e.message}") + } + } + + private inline fun resolveContextMappedConfig( + jsonObject: JsonObject, + parseFromObject: (JsonObject) -> T + ): T? { + val userIdKey = userContextProvider.getCurrentUserIdKey() + logger.i("Resolving context-mapped config for user ID key: $userIdKey") + + // Try to find config by user ID key + val configObject = jsonObject[userIdKey]?.jsonObject + ?: jsonObject[AndroidUserContextProvider.DEFAULT_KEY]?.jsonObject + + return if (configObject != null) { + val resolvedKey = if (jsonObject.containsKey(userIdKey)) userIdKey else AndroidUserContextProvider.DEFAULT_KEY + logger.i("Resolved config using key: $resolvedKey") + parseFromObject(configObject) + } else { + logger.w("No config found for user ID key '$userIdKey' and no '${AndroidUserContextProvider.DEFAULT_KEY}' fallback") + null + } + } + + /** + * Unified server format has "endpoints" and "title" at the root level. + */ + private fun isUnifiedServerFormat(jsonObject: JsonObject): Boolean = + jsonObject.containsKey(KEY_ENDPOINTS) && jsonObject.containsKey(KEY_TITLE) + + /** + * Unified SSO format has "sso_code" at the root level. + */ + private fun isUnifiedSSOFormat(jsonObject: JsonObject): Boolean = + jsonObject.containsKey(KEY_SSO_CODE) + + companion object { + private const val TAG = "ManagedConfigParser" + private const val KEY_ENDPOINTS = "endpoints" + private const val KEY_TITLE = "title" + private const val KEY_SSO_CODE = "sso_code" + } +} diff --git a/app/src/main/kotlin/com/wire/android/emm/ManagedConfigurationsManager.kt b/app/src/main/kotlin/com/wire/android/emm/ManagedConfigurationsManager.kt index dc285e16b44..ace6c279df1 100644 --- a/app/src/main/kotlin/com/wire/android/emm/ManagedConfigurationsManager.kt +++ b/app/src/main/kotlin/com/wire/android/emm/ManagedConfigurationsManager.kt @@ -25,7 +25,6 @@ import com.wire.android.util.EMPTY import com.wire.android.util.dispatchers.DispatcherProvider import com.wire.kalium.logic.configuration.server.ServerConfig import kotlinx.coroutines.withContext -import kotlinx.serialization.json.Json import java.util.concurrent.atomic.AtomicReference interface ManagedConfigurationsManager { @@ -72,9 +71,9 @@ internal class ManagedConfigurationsManagerImpl( private val context: Context, private val dispatchers: DispatcherProvider, private val serverConfigProvider: ServerConfigProvider, + private val configParser: ManagedConfigParser, ) : ManagedConfigurationsManager { - private val json: Json = Json { ignoreUnknownKeys = true } private val logger = appLogger.withTextTag(TAG) private val restrictionsManager: RestrictionsManager by lazy { context.getSystemService(Context.RESTRICTIONS_SERVICE) as RestrictionsManager @@ -126,14 +125,21 @@ internal class ManagedConfigurationsManagerImpl( return@withContext SSOCodeConfigResult.Empty } + val rawJson = restrictions.getString(ManagedConfigurationsKeys.SSO_CODE.asKey()) + if (rawJson.isNullOrBlank()) { + logger.i("No SSO code restriction found") + return@withContext SSOCodeConfigResult.Empty + } + return@withContext try { - val ssoCode = getJsonRestrictionByKey( - ManagedConfigurationsKeys.SSO_CODE.asKey() - ) + val ssoCode = configParser.parseSSOCodeConfig(rawJson) if (ssoCode?.isValid == true) { logger.i("Managed SSO code found: $ssoCode") SSOCodeConfigResult.Success(ssoCode) + } else if (ssoCode == null) { + logger.w("No SSO code config resolved for current user context") + SSOCodeConfigResult.Empty } else { logger.w("Managed SSO code is not valid: $ssoCode") SSOCodeConfigResult.Failure("Managed SSO code is not a valid config. Check the format.") @@ -151,13 +157,20 @@ internal class ManagedConfigurationsManagerImpl( return@withContext ServerConfigResult.Empty } + val rawJson = restrictions.getString(ManagedConfigurationsKeys.DEFAULT_SERVER_URLS.asKey()) + if (rawJson.isNullOrBlank()) { + logger.i("No server config restriction found") + return@withContext ServerConfigResult.Empty + } + return@withContext try { - val managedServerConfig = getJsonRestrictionByKey( - ManagedConfigurationsKeys.DEFAULT_SERVER_URLS.asKey() - ) + val managedServerConfig = configParser.parseServerConfig(rawJson) if (managedServerConfig?.endpoints?.isValid == true) { logger.i("Managed server config found: $managedServerConfig") ServerConfigResult.Success(managedServerConfig) + } else if (managedServerConfig == null) { + logger.w("No server config resolved for current user context") + ServerConfigResult.Empty } else { logger.w("Managed server config is not valid: $managedServerConfig") ServerConfigResult.Failure("Managed server config is not a valid config. Check the URLs and format.") @@ -168,16 +181,6 @@ internal class ManagedConfigurationsManagerImpl( } } - @Suppress("TooGenericExceptionCaught") - private inline fun getJsonRestrictionByKey(key: String): T? = - restrictionsManager.applicationRestrictions.getString(key)?.let { - try { - json.decodeFromString(it) - } catch (e: Exception) { - throw InvalidManagedConfig("Failed to parse managed config for key $key: ${e.message}") - } - } - companion object { private const val TAG = "ManagedConfigurationsManager" } diff --git a/app/src/test/kotlin/com/wire/android/emm/AndroidUserContextProviderTest.kt b/app/src/test/kotlin/com/wire/android/emm/AndroidUserContextProviderTest.kt new file mode 100644 index 00000000000..64483818d31 --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/emm/AndroidUserContextProviderTest.kt @@ -0,0 +1,72 @@ +/* + * Wire + * Copyright (C) 2025 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.emm + +import org.junit.Assert.assertEquals +import org.junit.Test + +class AndroidUserContextProviderTest { + + @Test + fun `given UID in main user range, then user ID should be 0`() { + val provider = FakeAndroidUserContextProvider(uid = 10123) // UID 10123 / 100000 = 0 + assertEquals(0, provider.getCurrentAndroidUserId()) + assertEquals("0", provider.getCurrentUserIdKey()) + } + + @Test + fun `given UID in work profile range, then user ID should be 10`() { + val provider = FakeAndroidUserContextProvider(uid = 1010123) // UID 1010123 / 100000 = 10 + assertEquals(10, provider.getCurrentAndroidUserId()) + assertEquals("10", provider.getCurrentUserIdKey()) + } + + @Test + fun `given UID at exact boundary, then user ID should be calculated correctly`() { + val provider = FakeAndroidUserContextProvider(uid = 100000) // UID 100000 / 100000 = 1 + assertEquals(1, provider.getCurrentAndroidUserId()) + assertEquals("1", provider.getCurrentUserIdKey()) + } + + @Test + fun `given UID zero, then user ID should be 0`() { + val provider = FakeAndroidUserContextProvider(uid = 0) + assertEquals(0, provider.getCurrentAndroidUserId()) + assertEquals("0", provider.getCurrentUserIdKey()) + } + + @Test + fun `given UID just below boundary, then user ID should be 0`() { + val provider = FakeAndroidUserContextProvider(uid = 99999) + assertEquals(0, provider.getCurrentAndroidUserId()) + assertEquals("0", provider.getCurrentUserIdKey()) + } + + @Test + fun `given DEFAULT_KEY constant, then it should be 'default'`() { + assertEquals("default", AndroidUserContextProvider.DEFAULT_KEY) + } +} + +/** + * Fake implementation for testing that allows injecting a specific UID value. + */ +class FakeAndroidUserContextProvider(private val uid: Int) : AndroidUserContextProvider { + override fun getCurrentAndroidUserId(): Int = uid / AndroidUserContextProvider.UID_DIVISOR + override fun getCurrentUserIdKey(): String = getCurrentAndroidUserId().toString() +} diff --git a/app/src/test/kotlin/com/wire/android/emm/ManagedConfigParserTest.kt b/app/src/test/kotlin/com/wire/android/emm/ManagedConfigParserTest.kt new file mode 100644 index 00000000000..3157d169a1c --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/emm/ManagedConfigParserTest.kt @@ -0,0 +1,301 @@ +/* + * Wire + * Copyright (C) 2025 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.emm + +import android.app.Application +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(application = Application::class) +class ManagedConfigParserTest { + + // region Server Config - Unified Format + + @Test + fun `given unified server config, then parse correctly`() { + val parser = createParser(userIdKey = "0") + val result = parser.parseServerConfig(UNIFIED_SERVER_CONFIG) + + assertNotNull(result) + assertEquals("anta.wire.link", result!!.title) + assertEquals("https://account.anta.wire.link", result.endpoints.accountsURL) + assertEquals("https://nginz-https.anta.wire.link", result.endpoints.backendURL) + assertEquals("https://nginz-ssl.anta.wire.link", result.endpoints.backendWSURL) + assertEquals("https://disallowed-clients.anta.wire.link", result.endpoints.blackListURL) + assertEquals("https://teams.anta.wire.link", result.endpoints.teamsURL) + assertEquals("https://wire.com", result.endpoints.websiteURL) + } + + // endregion + + // region Server Config - Context-Mapped Format + + @Test + fun `given context-mapped server config with matching user ID, then return correct config`() { + val parser = createParser(userIdKey = "0") + val result = parser.parseServerConfig(CONTEXT_MAPPED_SERVER_CONFIG) + + assertNotNull(result) + assertEquals("Secure Server", result!!.title) + assertEquals("https://secure-account.wire.link", result.endpoints.accountsURL) + } + + @Test + fun `given context-mapped server config with non-matching user ID, then fallback to default`() { + val parser = createParser(userIdKey = "99") // User ID not in config + val result = parser.parseServerConfig(CONTEXT_MAPPED_SERVER_CONFIG) + + assertNotNull(result) + assertEquals("General Server", result!!.title) + assertEquals("https://general-account.wire.link", result.endpoints.accountsURL) + } + + @Test + fun `given context-mapped server config without default and non-matching user ID, then return null`() { + val parser = createParser(userIdKey = "99") + val result = parser.parseServerConfig(CONTEXT_MAPPED_SERVER_CONFIG_NO_DEFAULT) + + assertNull(result) + } + + @Test + fun `given context-mapped server config with only default, then use default for any user`() { + val parser = createParser(userIdKey = "42") + val result = parser.parseServerConfig(CONTEXT_MAPPED_SERVER_CONFIG_ONLY_DEFAULT) + + assertNotNull(result) + assertEquals("Default Server", result!!.title) + } + + // endregion + + // region SSO Config - Unified Format + + @Test + fun `given unified SSO config, then parse correctly`() { + val parser = createParser(userIdKey = "0") + val result = parser.parseSSOCodeConfig(UNIFIED_SSO_CONFIG) + + assertNotNull(result) + assertEquals("fd994b20-b9af-11ec-ae36-00163e9b33ca", result!!.ssoCode) + } + + // endregion + + // region SSO Config - Context-Mapped Format + + @Test + fun `given context-mapped SSO config with matching user ID, then return correct config`() { + val parser = createParser(userIdKey = "0") + val result = parser.parseSSOCodeConfig(CONTEXT_MAPPED_SSO_CONFIG) + + assertNotNull(result) + assertEquals("secure-sso-code-0000-0000-000000000000", result!!.ssoCode) + } + + @Test + fun `given context-mapped SSO config with non-matching user ID, then fallback to default`() { + val parser = createParser(userIdKey = "99") + val result = parser.parseSSOCodeConfig(CONTEXT_MAPPED_SSO_CONFIG) + + assertNotNull(result) + assertEquals("default-sso-code-0000-0000-000000000000", result!!.ssoCode) + } + + @Test + fun `given context-mapped SSO config without default and non-matching user ID, then return null`() { + val parser = createParser(userIdKey = "99") + val result = parser.parseSSOCodeConfig(CONTEXT_MAPPED_SSO_CONFIG_NO_DEFAULT) + + assertNull(result) + } + + // endregion + + // region Invalid JSON + + @Test + fun `given invalid JSON, then throw InvalidManagedConfig`() { + val parser = createParser(userIdKey = "0") + + assertThrows(InvalidManagedConfig::class.java) { + parser.parseServerConfig("invalid json") + } + } + + @Test + fun `given empty JSON object for server config, then return null as no context matched`() { + val parser = createParser(userIdKey = "0") + // Empty JSON object {} has no keys matching user ID or "default" + val result = parser.parseServerConfig("{}") + assertNull(result) + } + + @Test + fun `given malformed server config with partial unified format, then return null`() { + val parser = createParser(userIdKey = "0") + // This JSON has title but no endpoints - doesn't match unified format, falls through to context-mapped + // Since it has no matching context keys, returns null + val malformedJson = """{"title": "Test"}""" + + val result = parser.parseServerConfig(malformedJson) + assertNull(result) + } + + @Test + fun `given completely invalid JSON structure, then throw InvalidManagedConfig`() { + val parser = createParser(userIdKey = "0") + // Not a valid JSON object structure + assertThrows(InvalidManagedConfig::class.java) { + parser.parseServerConfig("not json at all") + } + } + + // endregion + + // region Helper Methods + + private fun createParser(userIdKey: String): ManagedConfigParser { + return ManagedConfigParserImpl( + userContextProvider = object : AndroidUserContextProvider { + override fun getCurrentAndroidUserId(): Int = userIdKey.toIntOrNull() ?: 0 + override fun getCurrentUserIdKey(): String = userIdKey + } + ) + } + + // endregion + + companion object { + val UNIFIED_SERVER_CONFIG = """ + { + "endpoints": { + "accountsURL": "https://account.anta.wire.link", + "backendURL": "https://nginz-https.anta.wire.link", + "backendWSURL": "https://nginz-ssl.anta.wire.link", + "blackListURL": "https://disallowed-clients.anta.wire.link", + "teamsURL": "https://teams.anta.wire.link", + "websiteURL": "https://wire.com" + }, + "title": "anta.wire.link" + } + """.trimIndent() + + val CONTEXT_MAPPED_SERVER_CONFIG = """ + { + "0": { + "title": "Secure Server", + "endpoints": { + "accountsURL": "https://secure-account.wire.link", + "backendURL": "https://secure-api.wire.link", + "backendWSURL": "https://secure-ws.wire.link", + "blackListURL": "https://secure-blacklist.wire.link", + "teamsURL": "https://secure-teams.wire.link", + "websiteURL": "https://secure.wire.com" + } + }, + "default": { + "title": "General Server", + "endpoints": { + "accountsURL": "https://general-account.wire.link", + "backendURL": "https://general-api.wire.link", + "backendWSURL": "https://general-ws.wire.link", + "blackListURL": "https://general-blacklist.wire.link", + "teamsURL": "https://general-teams.wire.link", + "websiteURL": "https://general.wire.com" + } + } + } + """.trimIndent() + + val CONTEXT_MAPPED_SERVER_CONFIG_NO_DEFAULT = """ + { + "0": { + "title": "Secure Server", + "endpoints": { + "accountsURL": "https://secure-account.wire.link", + "backendURL": "https://secure-api.wire.link", + "backendWSURL": "https://secure-ws.wire.link", + "blackListURL": "https://secure-blacklist.wire.link", + "teamsURL": "https://secure-teams.wire.link", + "websiteURL": "https://secure.wire.com" + } + }, + "10": { + "title": "Work Profile Server", + "endpoints": { + "accountsURL": "https://work-account.wire.link", + "backendURL": "https://work-api.wire.link", + "backendWSURL": "https://work-ws.wire.link", + "blackListURL": "https://work-blacklist.wire.link", + "teamsURL": "https://work-teams.wire.link", + "websiteURL": "https://work.wire.com" + } + } + } + """.trimIndent() + + val CONTEXT_MAPPED_SERVER_CONFIG_ONLY_DEFAULT = """ + { + "default": { + "title": "Default Server", + "endpoints": { + "accountsURL": "https://default-account.wire.link", + "backendURL": "https://default-api.wire.link", + "backendWSURL": "https://default-ws.wire.link", + "blackListURL": "https://default-blacklist.wire.link", + "teamsURL": "https://default-teams.wire.link", + "websiteURL": "https://default.wire.com" + } + } + } + """.trimIndent() + + val UNIFIED_SSO_CONFIG = """ + { + "sso_code": "fd994b20-b9af-11ec-ae36-00163e9b33ca" + } + """.trimIndent() + + val CONTEXT_MAPPED_SSO_CONFIG = """ + { + "0": { + "sso_code": "secure-sso-code-0000-0000-000000000000" + }, + "default": { + "sso_code": "default-sso-code-0000-0000-000000000000" + } + } + """.trimIndent() + + val CONTEXT_MAPPED_SSO_CONFIG_NO_DEFAULT = """ + { + "0": { + "sso_code": "secure-sso-code-0000-0000-000000000000" + } + } + """.trimIndent() + } +} diff --git a/app/src/test/kotlin/com/wire/android/emm/ManagedConfigurationsManagerTest.kt b/app/src/test/kotlin/com/wire/android/emm/ManagedConfigurationsManagerTest.kt index 328b04b553a..fca9d5b116c 100644 --- a/app/src/test/kotlin/com/wire/android/emm/ManagedConfigurationsManagerTest.kt +++ b/app/src/test/kotlin/com/wire/android/emm/ManagedConfigurationsManagerTest.kt @@ -1,3 +1,20 @@ +/* + * Wire + * Copyright (C) 2025 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ package com.wire.android.emm import android.app.Application @@ -21,6 +38,8 @@ import org.robolectric.annotation.Config @Config(application = Application::class) class ManagedConfigurationsManagerTest { + // region Unified Format Tests (Backward Compatibility) + @Test fun `given a server config is valid, then parse it to a corresponding ManagedServerConfig`() = runTest { @@ -126,9 +145,112 @@ class ManagedConfigurationsManagerTest { assertEquals(ServerConfigProvider().getDefaultServerConfig(), serverConfig) } + // endregion + + // region Context-Mapped Format Tests (Multi-App Support) + + @Test + fun `given context-mapped server config with matching user ID, then return correct config`() = + runTest { + val (_, manager) = Arrangement() + .withUserIdKey("0") + .withRestrictions(mapOf(ManagedConfigurationsKeys.DEFAULT_SERVER_URLS.asKey() to contextMappedServerConfigJson)) + .arrange() + + val result = manager.refreshServerConfig() + assertInstanceOf(result) + + val serverConfig = manager.currentServerConfig + assertEquals("Secure Server", serverConfig.title) + assertEquals("https://secure-account.wire.link", serverConfig.accounts) + } + + @Test + fun `given context-mapped server config with non-matching user ID, then fallback to default`() = + runTest { + val (_, manager) = Arrangement() + .withUserIdKey("99") // User ID not in config + .withRestrictions(mapOf(ManagedConfigurationsKeys.DEFAULT_SERVER_URLS.asKey() to contextMappedServerConfigJson)) + .arrange() + + val result = manager.refreshServerConfig() + assertInstanceOf(result) + + val serverConfig = manager.currentServerConfig + assertEquals("General Server", serverConfig.title) + assertEquals("https://general-account.wire.link", serverConfig.accounts) + } + + @Test + fun `given context-mapped server config without default and non-matching user ID, then return empty`() = + runTest { + val (_, manager) = Arrangement() + .withUserIdKey("99") + .withRestrictions(mapOf(ManagedConfigurationsKeys.DEFAULT_SERVER_URLS.asKey() to contextMappedServerConfigNoDefaultJson)) + .arrange() + + val result = manager.refreshServerConfig() + assertInstanceOf(result) + + val serverConfig = manager.currentServerConfig + assertEquals(ServerConfigProvider().getDefaultServerConfig(), serverConfig) + } + + @Test + fun `given context-mapped SSO config with matching user ID, then return correct SSO code`() = + runTest { + val (_, manager) = Arrangement() + .withUserIdKey("0") + .withRestrictions(mapOf(ManagedConfigurationsKeys.SSO_CODE.asKey() to contextMappedSSOConfigJson)) + .arrange() + + val result = manager.refreshSSOCodeConfig() + assertInstanceOf(result) + + val ssoCode = manager.currentSSOCodeConfig + assertEquals("00000000-0000-0000-0000-000000000000", ssoCode) + } + + @Test + fun `given context-mapped SSO config with non-matching user ID, then fallback to default`() = + runTest { + val (_, manager) = Arrangement() + .withUserIdKey("99") + .withRestrictions(mapOf(ManagedConfigurationsKeys.SSO_CODE.asKey() to contextMappedSSOConfigJson)) + .arrange() + + val result = manager.refreshSSOCodeConfig() + assertInstanceOf(result) + + val ssoCode = manager.currentSSOCodeConfig + assertEquals("fd994b20-b9af-11ec-ae36-00163e9b33ca", ssoCode) + } + + @Test + fun `given context-mapped SSO config without default and non-matching user ID, then return empty`() = + runTest { + val (_, manager) = Arrangement() + .withUserIdKey("99") + .withRestrictions(mapOf(ManagedConfigurationsKeys.SSO_CODE.asKey() to contextMappedSSOConfigNoDefaultJson)) + .arrange() + + val result = manager.refreshSSOCodeConfig() + assertInstanceOf(result) + + val ssoCode = manager.currentSSOCodeConfig + assertEquals(String.EMPTY, ssoCode) + } + + // endregion + private class Arrangement { private val context: Context = ApplicationProvider.getApplicationContext() + private var userIdKey: String = "0" + + fun withUserIdKey(userIdKey: String) = apply { + this.userIdKey = userIdKey + } fun withRestrictions(restrictions: Map) = apply { val restrictionsManager = @@ -143,11 +265,20 @@ class ManagedConfigurationsManagerTest { ) } - fun arrange() = this to ManagedConfigurationsManagerImpl( - context = context, - serverConfigProvider = ServerConfigProvider(), - dispatchers = TestDispatcherProvider() - ) + fun arrange(): Pair { + val userContextProvider = object : AndroidUserContextProvider { + override fun getCurrentAndroidUserId(): Int = userIdKey.toIntOrNull() ?: 0 + override fun getCurrentUserIdKey(): String = userIdKey + } + val configParser = ManagedConfigParserImpl(userContextProvider) + + return this to ManagedConfigurationsManagerImpl( + context = context, + serverConfigProvider = ServerConfigProvider(), + dispatchers = TestDispatcherProvider(), + configParser = configParser + ) + } } companion object { @@ -190,5 +321,67 @@ class ManagedConfigurationsManagerTest { "sso_code": "invalid-sso-code" } """.trimIndent() + + val contextMappedServerConfigJson = """ + { + "0": { + "title": "Secure Server", + "endpoints": { + "accountsURL": "https://secure-account.wire.link", + "backendURL": "https://secure-api.wire.link", + "backendWSURL": "https://secure-ws.wire.link", + "blackListURL": "https://secure-blacklist.wire.link", + "teamsURL": "https://secure-teams.wire.link", + "websiteURL": "https://secure.wire.com" + } + }, + "default": { + "title": "General Server", + "endpoints": { + "accountsURL": "https://general-account.wire.link", + "backendURL": "https://general-api.wire.link", + "backendWSURL": "https://general-ws.wire.link", + "blackListURL": "https://general-blacklist.wire.link", + "teamsURL": "https://general-teams.wire.link", + "websiteURL": "https://general.wire.com" + } + } + } + """.trimIndent() + + val contextMappedServerConfigNoDefaultJson = """ + { + "0": { + "title": "Secure Server", + "endpoints": { + "accountsURL": "https://secure-account.wire.link", + "backendURL": "https://secure-api.wire.link", + "backendWSURL": "https://secure-ws.wire.link", + "blackListURL": "https://secure-blacklist.wire.link", + "teamsURL": "https://secure-teams.wire.link", + "websiteURL": "https://secure.wire.com" + } + } + } + """.trimIndent() + + val contextMappedSSOConfigJson = """ + { + "0": { + "sso_code": "00000000-0000-0000-0000-000000000000" + }, + "default": { + "sso_code": "fd994b20-b9af-11ec-ae36-00163e9b33ca" + } + } + """.trimIndent() + + val contextMappedSSOConfigNoDefaultJson = """ + { + "0": { + "sso_code": "00000000-0000-0000-0000-000000000000" + } + } + """.trimIndent() } }