Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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 {

Check warning on line 46 in app/src/main/kotlin/com/wire/android/emm/AndroidUserContextProvider.kt

View check run for this annotation

Codecov / codecov/patch

app/src/main/kotlin/com/wire/android/emm/AndroidUserContextProvider.kt#L46

Added line #L46 was not covered by tests
const val DEFAULT_KEY = "default"
internal const val UID_DIVISOR = 100_000
}
}

internal class AndroidUserContextProviderImpl : AndroidUserContextProvider {

Check warning on line 52 in app/src/main/kotlin/com/wire/android/emm/AndroidUserContextProvider.kt

View check run for this annotation

Codecov / codecov/patch

app/src/main/kotlin/com/wire/android/emm/AndroidUserContextProvider.kt#L52

Added line #L52 was not covered by tests

override fun getCurrentAndroidUserId(): Int =
Process.myUid() / AndroidUserContextProvider.UID_DIVISOR

Check warning on line 55 in app/src/main/kotlin/com/wire/android/emm/AndroidUserContextProvider.kt

View check run for this annotation

Codecov / codecov/patch

app/src/main/kotlin/com/wire/android/emm/AndroidUserContextProvider.kt#L55

Added line #L55 was not covered by tests

override fun getCurrentUserIdKey(): String =
getCurrentAndroidUserId().toString()

Check warning on line 58 in app/src/main/kotlin/com/wire/android/emm/AndroidUserContextProvider.kt

View check run for this annotation

Codecov / codecov/patch

app/src/main/kotlin/com/wire/android/emm/AndroidUserContextProvider.kt#L58

Added line #L58 was not covered by tests
}
157 changes: 157 additions & 0 deletions app/src/main/kotlin/com/wire/android/emm/ManagedConfigParser.kt
Original file line number Diff line number Diff line change
@@ -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<ManagedServerConfig>(rawJson) },
parseFromObject = { json.decodeFromJsonElement<ManagedServerConfig>(it) }
)
}

override fun parseSSOCodeConfig(rawJson: String): ManagedSSOCodeConfig? {
return parseConfig(
rawJson = rawJson,
configType = "SSO",
isUnifiedFormat = ::isUnifiedSSOFormat,
parseUnified = { json.decodeFromString<ManagedSSOCodeConfig>(rawJson) },
parseFromObject = { json.decodeFromJsonElement<ManagedSSOCodeConfig>(it) }
)
}

@Suppress("TooGenericExceptionCaught")
private inline fun <T> 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 <T> 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"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@
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 {
Expand Down Expand Up @@ -72,9 +71,9 @@
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
Expand Down Expand Up @@ -126,14 +125,21 @@
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

Check warning on line 131 in app/src/main/kotlin/com/wire/android/emm/ManagedConfigurationsManager.kt

View check run for this annotation

Codecov / codecov/patch

app/src/main/kotlin/com/wire/android/emm/ManagedConfigurationsManager.kt#L130-L131

Added lines #L130 - L131 were not covered by tests
}

return@withContext try {
val ssoCode = getJsonRestrictionByKey<ManagedSSOCodeConfig>(
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.")
Expand All @@ -151,13 +157,20 @@
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

Check warning on line 163 in app/src/main/kotlin/com/wire/android/emm/ManagedConfigurationsManager.kt

View check run for this annotation

Codecov / codecov/patch

app/src/main/kotlin/com/wire/android/emm/ManagedConfigurationsManager.kt#L162-L163

Added lines #L162 - L163 were not covered by tests
}

return@withContext try {
val managedServerConfig = getJsonRestrictionByKey<ManagedServerConfig>(
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.")
Expand All @@ -168,16 +181,6 @@
}
}

@Suppress("TooGenericExceptionCaught")
private inline fun <reified T> getJsonRestrictionByKey(key: String): T? =
restrictionsManager.applicationRestrictions.getString(key)?.let {
try {
json.decodeFromString<T>(it)
} catch (e: Exception) {
throw InvalidManagedConfig("Failed to parse managed config for key $key: ${e.message}")
}
}

companion object {
private const val TAG = "ManagedConfigurationsManager"
}
Expand Down
Loading
Loading