From 566e838457e7f7ed6f4712d3c592a3eb55b84a9a Mon Sep 17 00:00:00 2001 From: Mohamad Jaara Date: Mon, 2 Feb 2026 11:00:31 +0100 Subject: [PATCH 1/5] feat: add support for enforcing persistent WebSocket connection via MDM --- .../android/emm/ManagedConfigurationsKeys.kt | 3 +- .../emm/ManagedConfigurationsManager.kt | 36 +++++++++++ .../emm/ManagedConfigurationsReceiver.kt | 11 ++++ ...dStartPersistentWebSocketServiceUseCase.kt | 9 ++- .../networkSettings/NetworkSettingsScreen.kt | 21 +++++-- .../networkSettings/NetworkSettingsState.kt | 3 +- .../NetworkSettingsViewModel.kt | 21 ++++++- app/src/main/res/values/strings.xml | 3 + app/src/main/res/xml/app_restrictions.xml | 8 +++ .../emm/ManagedConfigurationsManagerTest.kt | 43 +++++++++++++ ...rtPersistentWebSocketServiceUseCaseTest.kt | 61 ++++++++++++++++++- kalium | 2 +- 12 files changed, 209 insertions(+), 12 deletions(-) diff --git a/app/src/main/kotlin/com/wire/android/emm/ManagedConfigurationsKeys.kt b/app/src/main/kotlin/com/wire/android/emm/ManagedConfigurationsKeys.kt index a29bec76371..8b9984f1829 100644 --- a/app/src/main/kotlin/com/wire/android/emm/ManagedConfigurationsKeys.kt +++ b/app/src/main/kotlin/com/wire/android/emm/ManagedConfigurationsKeys.kt @@ -19,7 +19,8 @@ package com.wire.android.emm enum class ManagedConfigurationsKeys { DEFAULT_SERVER_URLS, - SSO_CODE; + SSO_CODE, + KEEP_WEBSOCKET_CONNECTION; fun asKey() = name.lowercase() } 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..d67804ae1a7 100644 --- a/app/src/main/kotlin/com/wire/android/emm/ManagedConfigurationsManager.kt +++ b/app/src/main/kotlin/com/wire/android/emm/ManagedConfigurationsManager.kt @@ -24,6 +24,9 @@ import com.wire.android.config.ServerConfigProvider import com.wire.android.util.EMPTY import com.wire.android.util.dispatchers.DispatcherProvider import com.wire.kalium.logic.configuration.server.ServerConfig +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json import java.util.concurrent.atomic.AtomicReference @@ -66,6 +69,19 @@ interface ManagedConfigurationsManager { * empty if no config found or cleared, or failure with reason. */ suspend fun refreshSSOCodeConfig(): SSOCodeConfigResult + + /** + * Whether persistent WebSocket connection is enforced by MDM. + * When true, the persistent WebSocket service should always be started + * and users should not be able to change this setting. + */ + val persistentWebSocketEnforcedByMDM: StateFlow + + /** + * Refresh the persistent WebSocket configuration from managed restrictions. + * This should be called when the app starts or when broadcast receiver triggers. + */ + suspend fun refreshPersistentWebSocketConfig() } internal class ManagedConfigurationsManagerImpl( @@ -82,6 +98,7 @@ internal class ManagedConfigurationsManagerImpl( private val _currentServerConfig = AtomicReference(null) private val _currentSSOCodeConfig = AtomicReference(String.EMPTY) + private val _persistentWebSocketEnforcedByMDM = MutableStateFlow(false) override val currentServerConfig: ServerConfig.Links get() = _currentServerConfig.get() ?: serverConfigProvider.getDefaultServerConfig() @@ -89,6 +106,9 @@ internal class ManagedConfigurationsManagerImpl( override val currentSSOCodeConfig: String get() = _currentSSOCodeConfig.get() + override val persistentWebSocketEnforcedByMDM: StateFlow + get() = _persistentWebSocketEnforcedByMDM.asStateFlow() + override suspend fun refreshServerConfig(): ServerConfigResult = withContext(dispatchers.io()) { val managedServerConfig = getServerConfig() val serverConfig: ServerConfig.Links = when (managedServerConfig) { @@ -118,6 +138,22 @@ internal class ManagedConfigurationsManagerImpl( managedSSOCodeConfig } + override suspend fun refreshPersistentWebSocketConfig() { + withContext(dispatchers.io()) { + val restrictions = restrictionsManager.applicationRestrictions + val isEnforced = if (restrictions == null || restrictions.isEmpty) { + false + } else { + restrictions.getBoolean( + ManagedConfigurationsKeys.KEEP_WEBSOCKET_CONNECTION.asKey(), + false + ) + } + _persistentWebSocketEnforcedByMDM.value = isEnforced + logger.i("Persistent WebSocket enforced by MDM refreshed to: $isEnforced") + } + } + private suspend fun getSSOCodeConfig(): SSOCodeConfigResult = withContext(dispatchers.io()) { val restrictions = restrictionsManager.applicationRestrictions diff --git a/app/src/main/kotlin/com/wire/android/emm/ManagedConfigurationsReceiver.kt b/app/src/main/kotlin/com/wire/android/emm/ManagedConfigurationsReceiver.kt index f1ac6288862..40c865febcd 100644 --- a/app/src/main/kotlin/com/wire/android/emm/ManagedConfigurationsReceiver.kt +++ b/app/src/main/kotlin/com/wire/android/emm/ManagedConfigurationsReceiver.kt @@ -48,6 +48,7 @@ class ManagedConfigurationsReceiver @Inject constructor( logger.i("Received intent to refresh managed configurations") updateServerConfig() updateSSOCodeConfig() + updatePersistentWebSocketConfig() } } @@ -103,6 +104,16 @@ class ManagedConfigurationsReceiver @Inject constructor( } } + private suspend fun updatePersistentWebSocketConfig() { + managedConfigurationsManager.refreshPersistentWebSocketConfig() + val isEnforced = managedConfigurationsManager.persistentWebSocketEnforcedByMDM.value + managedConfigurationsReporter.reportAppliedState( + key = ManagedConfigurationsKeys.KEEP_WEBSOCKET_CONNECTION.asKey(), + message = if (isEnforced) "Persistent WebSocket enforced" else "Persistent WebSocket not enforced", + data = isEnforced.toString() + ) + } + companion object { private const val TAG = "ManagedConfigurationsReceiver" } diff --git a/app/src/main/kotlin/com/wire/android/feature/ShouldStartPersistentWebSocketServiceUseCase.kt b/app/src/main/kotlin/com/wire/android/feature/ShouldStartPersistentWebSocketServiceUseCase.kt index 9239a77586a..5d6ebdd8ba3 100644 --- a/app/src/main/kotlin/com/wire/android/feature/ShouldStartPersistentWebSocketServiceUseCase.kt +++ b/app/src/main/kotlin/com/wire/android/feature/ShouldStartPersistentWebSocketServiceUseCase.kt @@ -18,6 +18,7 @@ package com.wire.android.feature import com.wire.android.di.KaliumCoreLogic +import com.wire.android.emm.ManagedConfigurationsManager import com.wire.kalium.logic.CoreLogic import com.wire.kalium.logic.feature.user.webSocketStatus.ObservePersistentWebSocketConnectionStatusUseCase import kotlinx.coroutines.flow.firstOrNull @@ -27,9 +28,15 @@ import javax.inject.Singleton @Singleton class ShouldStartPersistentWebSocketServiceUseCase @Inject constructor( - @KaliumCoreLogic private val coreLogic: CoreLogic + @KaliumCoreLogic private val coreLogic: CoreLogic, + private val managedConfigurationsManager: ManagedConfigurationsManager ) { suspend operator fun invoke(): Result { + // MDM takes priority - if enforced, always start service + if (managedConfigurationsManager.persistentWebSocketEnforcedByMDM.value) { + return Result.Success(true) + } + return coreLogic.getGlobalScope().observePersistentWebSocketConnectionStatus().let { result -> when (result) { is ObservePersistentWebSocketConnectionStatusUseCase.Result.Failure -> Result.Failure diff --git a/app/src/main/kotlin/com/wire/android/ui/home/settings/appsettings/networkSettings/NetworkSettingsScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/settings/appsettings/networkSettings/NetworkSettingsScreen.kt index 6b74b22988d..b92699177c7 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/settings/appsettings/networkSettings/NetworkSettingsScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/settings/appsettings/networkSettings/NetworkSettingsScreen.kt @@ -49,6 +49,7 @@ fun NetworkSettingsScreen( NetworkSettingsScreenContent( onBackPressed = navigator::navigateBack, isWebSocketEnabled = networkSettingsViewModel.networkSettingsState.isPersistentWebSocketConnectionEnabled, + isEnforcedByMDM = networkSettingsViewModel.networkSettingsState.isEnforcedByMDM, setWebSocketState = { networkSettingsViewModel.setWebSocketState(it) }, ) } @@ -57,6 +58,7 @@ fun NetworkSettingsScreen( fun NetworkSettingsScreenContent( onBackPressed: () -> Unit, isWebSocketEnabled: Boolean, + isEnforcedByMDM: Boolean, setWebSocketState: (Boolean) -> Unit, modifier: Modifier = Modifier ) { @@ -79,20 +81,26 @@ fun NetworkSettingsScreenContent( val isWebSocketEnforcedByDefault = remember { isWebsocketEnabledByDefault(appContext) } - val switchState = remember(isWebSocketEnabled) { - if (isWebSocketEnforcedByDefault) { - SwitchState.TextOnly(true) - } else { - SwitchState.Enabled( + val switchState = remember(isWebSocketEnabled, isEnforcedByMDM) { + when { + isEnforcedByMDM -> SwitchState.TextOnly(true) + isWebSocketEnforcedByDefault -> SwitchState.TextOnly(true) + else -> SwitchState.Enabled( value = isWebSocketEnabled, onCheckedChange = setWebSocketState ) } } + val subtitle = if (isEnforcedByMDM) { + stringResource(R.string.settings_keep_websocket_enforced_by_organization) + } else { + stringResource(R.string.settings_keep_connection_to_websocket_description) + } + GroupConversationOptionsItem( title = stringResource(R.string.settings_keep_connection_to_websocket), - subtitle = stringResource(R.string.settings_keep_connection_to_websocket_description), + subtitle = subtitle, switchState = switchState, arrowType = ArrowType.NONE ) @@ -106,6 +114,7 @@ fun PreviewNetworkSettingsScreen() = WireTheme { NetworkSettingsScreenContent( onBackPressed = {}, isWebSocketEnabled = true, + isEnforcedByMDM = false, setWebSocketState = {}, ) } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/settings/appsettings/networkSettings/NetworkSettingsState.kt b/app/src/main/kotlin/com/wire/android/ui/home/settings/appsettings/networkSettings/NetworkSettingsState.kt index 9a08cbb5516..2c1c8d6d997 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/settings/appsettings/networkSettings/NetworkSettingsState.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/settings/appsettings/networkSettings/NetworkSettingsState.kt @@ -19,5 +19,6 @@ package com.wire.android.ui.home.settings.appsettings.networkSettings data class NetworkSettingsState( - val isPersistentWebSocketConnectionEnabled: Boolean = false + val isPersistentWebSocketConnectionEnabled: Boolean = false, + val isEnforcedByMDM: Boolean = false ) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/settings/appsettings/networkSettings/NetworkSettingsViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/settings/appsettings/networkSettings/NetworkSettingsViewModel.kt index afead2d62d6..ee6986ee225 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/settings/appsettings/networkSettings/NetworkSettingsViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/settings/appsettings/networkSettings/NetworkSettingsViewModel.kt @@ -24,6 +24,7 @@ import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.wire.android.appLogger +import com.wire.android.emm.ManagedConfigurationsManager import com.wire.kalium.logic.feature.session.CurrentSessionResult import com.wire.kalium.logic.feature.session.CurrentSessionUseCase import com.wire.kalium.logic.feature.user.webSocketStatus.ObservePersistentWebSocketConnectionStatusUseCase @@ -37,12 +38,26 @@ class NetworkSettingsViewModel @Inject constructor( private val persistPersistentWebSocketConnectionStatus: PersistPersistentWebSocketConnectionStatusUseCase, private val observePersistentWebSocketConnectionStatus: ObservePersistentWebSocketConnectionStatusUseCase, - private val currentSession: CurrentSessionUseCase + private val currentSession: CurrentSessionUseCase, + private val managedConfigurationsManager: ManagedConfigurationsManager ) : ViewModel() { var networkSettingsState by mutableStateOf(NetworkSettingsState()) init { observePersistentWebSocketConnection() + observeMDMEnforcement() + } + + private fun observeMDMEnforcement() { + viewModelScope.launch { + managedConfigurationsManager.persistentWebSocketEnforcedByMDM.collect { isEnforced -> + networkSettingsState = networkSettingsState.copy( + isEnforcedByMDM = isEnforced, + isPersistentWebSocketConnectionEnabled = if (isEnforced) true + else networkSettingsState.isPersistentWebSocketConnectionEnabled + ) + } + } } private fun observePersistentWebSocketConnection() = @@ -80,6 +95,10 @@ class NetworkSettingsViewModel } fun setWebSocketState(isEnabled: Boolean) { + // Block changes when MDM enforces the setting + if (networkSettingsState.isEnforcedByMDM) { + return + } viewModelScope.launch { persistPersistentWebSocketConnectionStatus(isEnabled) } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6c1856d9e18..3f7ae4749d4 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1875,6 +1875,9 @@ In group conversations, the group admin can overwrite this setting. SSO code configuration JSON value with the server endpoints configuration JSON value with the default SSO code configuration + Keep WebSocket Connection + Force persistent WebSocket connection for all users + This setting is managed by your organization. Channels are available for team members. diff --git a/app/src/main/res/xml/app_restrictions.xml b/app/src/main/res/xml/app_restrictions.xml index c43e1117b30..e6317d49ed6 100644 --- a/app/src/main/res/xml/app_restrictions.xml +++ b/app/src/main/res/xml/app_restrictions.xml @@ -31,4 +31,12 @@ android:restrictionType="string" android:title="@string/restriction_sso_code_title" /> + + + 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..ba18717072b 100644 --- a/app/src/test/kotlin/com/wire/android/emm/ManagedConfigurationsManagerTest.kt +++ b/app/src/test/kotlin/com/wire/android/emm/ManagedConfigurationsManagerTest.kt @@ -126,6 +126,36 @@ class ManagedConfigurationsManagerTest { assertEquals(ServerConfigProvider().getDefaultServerConfig(), serverConfig) } + @Test + fun `given keep_websocket_connection is true, then persistentWebSocketEnforcedByMDM returns true`() = runTest { + val (_, manager) = Arrangement() + .withBooleanRestrictions(mapOf(ManagedConfigurationsKeys.KEEP_WEBSOCKET_CONNECTION.asKey() to true)) + .arrange() + + manager.refreshPersistentWebSocketConfig() + assertEquals(true, manager.persistentWebSocketEnforcedByMDM.value) + } + + @Test + fun `given keep_websocket_connection is false, then persistentWebSocketEnforcedByMDM returns false`() = runTest { + val (_, manager) = Arrangement() + .withBooleanRestrictions(mapOf(ManagedConfigurationsKeys.KEEP_WEBSOCKET_CONNECTION.asKey() to false)) + .arrange() + + manager.refreshPersistentWebSocketConfig() + assertEquals(false, manager.persistentWebSocketEnforcedByMDM.value) + } + + @Test + fun `given no keep_websocket_connection restriction, then persistentWebSocketEnforcedByMDM returns false`() = runTest { + val (_, manager) = Arrangement() + .withRestrictions(emptyMap()) + .arrange() + + manager.refreshPersistentWebSocketConfig() + assertEquals(false, manager.persistentWebSocketEnforcedByMDM.value) + } + private class Arrangement { private val context: Context = ApplicationProvider.getApplicationContext() @@ -143,6 +173,19 @@ class ManagedConfigurationsManagerTest { ) } + fun withBooleanRestrictions(restrictions: Map) = apply { + val restrictionsManager = + context.getSystemService(Context.RESTRICTIONS_SERVICE) as RestrictionsManager + val shadowRestrictionsManager = Shadows.shadowOf(restrictionsManager) + shadowRestrictionsManager.setApplicationRestrictions( + Bundle().apply { + restrictions.forEach { (key, value) -> + putBoolean(key, value) + } + } + ) + } + fun arrange() = this to ManagedConfigurationsManagerImpl( context = context, serverConfigProvider = ServerConfigProvider(), diff --git a/app/src/test/kotlin/com/wire/android/feature/ShouldStartPersistentWebSocketServiceUseCaseTest.kt b/app/src/test/kotlin/com/wire/android/feature/ShouldStartPersistentWebSocketServiceUseCaseTest.kt index 79b46b87bb2..7015d99784a 100644 --- a/app/src/test/kotlin/com/wire/android/feature/ShouldStartPersistentWebSocketServiceUseCaseTest.kt +++ b/app/src/test/kotlin/com/wire/android/feature/ShouldStartPersistentWebSocketServiceUseCaseTest.kt @@ -17,15 +17,18 @@ */ package com.wire.android.feature +import com.wire.android.emm.ManagedConfigurationsManager import com.wire.kalium.logic.CoreLogic import com.wire.kalium.logic.data.auth.PersistentWebSocketStatus import com.wire.kalium.logic.data.user.UserId import com.wire.kalium.logic.feature.user.webSocketStatus.ObservePersistentWebSocketConnectionStatusUseCase import io.mockk.MockKAnnotations import io.mockk.coEvery +import io.mockk.every import io.mockk.impl.annotations.MockK import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.advanceTimeBy @@ -126,17 +129,70 @@ class ShouldStartPersistentWebSocketServiceUseCaseTest { assertInstanceOf(ShouldStartPersistentWebSocketServiceUseCase.Result.Failure::class.java, result) } + @Test + fun givenMDMEnforcesPersistentWebSocket_whenInvoking_shouldReturnSuccessTrue() = + runTest { + // given + val (_, useCase) = Arrangement() + .withMDMEnforcedPersistentWebSocket(true) + .arrange() + // when + val result = useCase.invoke() + // then + assertInstanceOf(ShouldStartPersistentWebSocketServiceUseCase.Result.Success::class.java, result).also { + assertEquals(true, it.shouldStartPersistentWebSocketService) + } + } + + @Test + fun givenMDMEnforcesPersistentWebSocket_whenInvoking_shouldNotCheckUserPreferences() = + runTest { + // given - MDM enforces, but user has it disabled + val (_, useCase) = Arrangement() + .withMDMEnforcedPersistentWebSocket(true) + .withObservePersistentWebSocketConnectionStatusSuccess(flowOf(listOf(PersistentWebSocketStatus(userId, false)))) + .arrange() + // when + val result = useCase.invoke() + // then - should still return true because MDM takes priority + assertInstanceOf(ShouldStartPersistentWebSocketServiceUseCase.Result.Success::class.java, result).also { + assertEquals(true, it.shouldStartPersistentWebSocketService) + } + } + + @Test + fun givenMDMDoesNotEnforcePersistentWebSocket_whenInvoking_shouldCheckUserPreferences() = + runTest { + // given - MDM does not enforce, user has it disabled + val (_, useCase) = Arrangement() + .withMDMEnforcedPersistentWebSocket(false) + .withObservePersistentWebSocketConnectionStatusSuccess(flowOf(listOf(PersistentWebSocketStatus(userId, false)))) + .arrange() + // when + val result = useCase.invoke() + // then - should return false because user has it disabled + assertInstanceOf(ShouldStartPersistentWebSocketServiceUseCase.Result.Success::class.java, result).also { + assertEquals(false, it.shouldStartPersistentWebSocketService) + } + } + inner class Arrangement { @MockK private lateinit var coreLogic: CoreLogic + @MockK + private lateinit var managedConfigurationsManager: ManagedConfigurationsManager + + private val persistentWebSocketEnforcedByMDMFlow = MutableStateFlow(false) + val useCase by lazy { - ShouldStartPersistentWebSocketServiceUseCase(coreLogic) + ShouldStartPersistentWebSocketServiceUseCase(coreLogic, managedConfigurationsManager) } init { MockKAnnotations.init(this, relaxUnitFun = true) + every { managedConfigurationsManager.persistentWebSocketEnforcedByMDM } returns persistentWebSocketEnforcedByMDMFlow } fun arrange() = this to useCase @@ -149,6 +205,9 @@ class ShouldStartPersistentWebSocketServiceUseCaseTest { coEvery { coreLogic.getGlobalScope().observePersistentWebSocketConnectionStatus() } returns ObservePersistentWebSocketConnectionStatusUseCase.Result.Failure.StorageFailure } + fun withMDMEnforcedPersistentWebSocket(enforced: Boolean) = apply { + persistentWebSocketEnforcedByMDMFlow.value = enforced + } } companion object { diff --git a/kalium b/kalium index ae71a9a75ae..3e25b914b76 160000 --- a/kalium +++ b/kalium @@ -1 +1 @@ -Subproject commit ae71a9a75ae63027c8970905ef5bdeabc59fee8b +Subproject commit 3e25b914b761cc04d9bff8ce2b8ebb59fd152357 From 11e2e764767416ffc088b41f510b44426e20ca33 Mon Sep 17 00:00:00 2001 From: Mohamad Jaara Date: Tue, 3 Feb 2026 10:05:23 +0100 Subject: [PATCH 2/5] detekt --- .../networkSettings/NetworkSettingsViewModel.kt | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/settings/appsettings/networkSettings/NetworkSettingsViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/settings/appsettings/networkSettings/NetworkSettingsViewModel.kt index ee6986ee225..648ddf20c8f 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/settings/appsettings/networkSettings/NetworkSettingsViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/settings/appsettings/networkSettings/NetworkSettingsViewModel.kt @@ -53,8 +53,11 @@ class NetworkSettingsViewModel managedConfigurationsManager.persistentWebSocketEnforcedByMDM.collect { isEnforced -> networkSettingsState = networkSettingsState.copy( isEnforcedByMDM = isEnforced, - isPersistentWebSocketConnectionEnabled = if (isEnforced) true - else networkSettingsState.isPersistentWebSocketConnectionEnabled + isPersistentWebSocketConnectionEnabled = if (isEnforced) { + true + } else { + networkSettingsState.isPersistentWebSocketConnectionEnabled + } ) } } @@ -72,6 +75,7 @@ class NetworkSettingsViewModel is ObservePersistentWebSocketConnectionStatusUseCase.Result.Failure -> { appLogger.e("Failure while fetching persistent web socket status flow from network settings") } + is ObservePersistentWebSocketConnectionStatusUseCase.Result.Success -> { it.persistentWebSocketStatusListFlow.collect { it.map { persistentWebSocketStatus -> @@ -79,7 +83,7 @@ class NetworkSettingsViewModel networkSettingsState = networkSettingsState.copy( isPersistentWebSocketConnectionEnabled = - persistentWebSocketStatus.isPersistentWebSocketEnabled + persistentWebSocketStatus.isPersistentWebSocketEnabled ) } } @@ -88,6 +92,7 @@ class NetworkSettingsViewModel } } } + else -> { // NO SESSION - Nothing to do } From 1270952b65c69ab8984e01bf52e56625d9cd7dff Mon Sep 17 00:00:00 2001 From: Mohamad Jaara Date: Tue, 3 Feb 2026 10:32:04 +0100 Subject: [PATCH 3/5] tests --- .../emm/ManagedConfigurationsReceiverTest.kt | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/app/src/test/kotlin/com/wire/android/emm/ManagedConfigurationsReceiverTest.kt b/app/src/test/kotlin/com/wire/android/emm/ManagedConfigurationsReceiverTest.kt index 96de544b576..734e6651a4d 100644 --- a/app/src/test/kotlin/com/wire/android/emm/ManagedConfigurationsReceiverTest.kt +++ b/app/src/test/kotlin/com/wire/android/emm/ManagedConfigurationsReceiverTest.kt @@ -25,7 +25,9 @@ import com.wire.android.config.TestDispatcherProvider import com.wire.android.util.EMPTY import io.mockk.coEvery import io.mockk.coVerify +import io.mockk.every import io.mockk.mockk +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import org.junit.Test @@ -63,6 +65,14 @@ class ManagedConfigurationsReceiverTest { any() ) } + coVerify(exactly = 1) { arrangement.managedConfigurationsManager.refreshPersistentWebSocketConfig() } + coVerify(exactly = 1) { + arrangement.managedConfigurationsReporter.reportAppliedState( + eq(ManagedConfigurationsKeys.KEEP_WEBSOCKET_CONNECTION.asKey()), + any(), + any() + ) + } } @Test @@ -183,8 +193,13 @@ class ManagedConfigurationsReceiverTest { val managedConfigurationsManager: ManagedConfigurationsManager = mockk(relaxed = true) val managedConfigurationsReporter: ManagedConfigurationsReporter = mockk(relaxed = true) private val dispatchers = TestDispatcherProvider() + private val persistentWebSocketEnforcedFlow = MutableStateFlow(false) lateinit var intent: Intent + init { + every { managedConfigurationsManager.persistentWebSocketEnforcedByMDM } returns persistentWebSocketEnforcedFlow + } + fun withIntent(action: String?) = apply { intent = if (action != null) Intent(action) else Intent() } @@ -197,6 +212,10 @@ class ManagedConfigurationsReceiverTest { coEvery { managedConfigurationsManager.refreshSSOCodeConfig() } returns result } + fun withPersistentWebSocketEnforced(enforced: Boolean) = apply { + persistentWebSocketEnforcedFlow.value = enforced + } + fun arrange() = this to ManagedConfigurationsReceiver( managedConfigurationsManager, managedConfigurationsReporter, From ea984bc2c879b8859b71bb13f5b573d713401e7d Mon Sep 17 00:00:00 2001 From: Mohamad Jaara Date: Tue, 3 Feb 2026 12:40:44 +0100 Subject: [PATCH 4/5] feat: implement bulk update for persistent WebSocket status via MDM --- .../wire/android/di/KaliumConfigsModule.kt | 11 +++- .../emm/ManagedConfigurationsReceiver.kt | 16 +++++ .../com/wire/android/util/WebsocketHelper.kt | 16 +++-- .../emm/ManagedConfigurationsReceiverTest.kt | 60 +++++++++++++++++++ .../wire/android/util/WebsocketHelperTest.kt | 49 +++++++++++++++ kalium | 2 +- 6 files changed, 147 insertions(+), 7 deletions(-) create mode 100644 app/src/test/kotlin/com/wire/android/util/WebsocketHelperTest.kt diff --git a/app/src/main/kotlin/com/wire/android/di/KaliumConfigsModule.kt b/app/src/main/kotlin/com/wire/android/di/KaliumConfigsModule.kt index fd15acb73f3..8066f7473ac 100644 --- a/app/src/main/kotlin/com/wire/android/di/KaliumConfigsModule.kt +++ b/app/src/main/kotlin/com/wire/android/di/KaliumConfigsModule.kt @@ -21,6 +21,7 @@ package com.wire.android.di import android.content.Context import android.os.Build import com.wire.android.BuildConfig +import com.wire.android.emm.ManagedConfigurationsManager import com.wire.android.util.isWebsocketEnabledByDefault import com.wire.kalium.logic.featureFlags.BuildFileRestrictionState import com.wire.kalium.logic.featureFlags.KaliumConfigs @@ -34,7 +35,10 @@ import dagger.hilt.components.SingletonComponent class KaliumConfigsModule { @Provides - fun provideKaliumConfigs(context: Context): KaliumConfigs { + fun provideKaliumConfigs( + context: Context, + managedConfigurationsManager: ManagedConfigurationsManager + ): KaliumConfigs { val fileRestriction: BuildFileRestrictionState = if (BuildConfig.FILE_RESTRICTION_ENABLED) { BuildConfig.FILE_RESTRICTION_LIST.split(",").map { it.trim() }.let { BuildFileRestrictionState.AllowSome(it) @@ -57,7 +61,10 @@ class KaliumConfigsModule { wipeOnCookieInvalid = BuildConfig.WIPE_ON_COOKIE_INVALID, wipeOnDeviceRemoval = BuildConfig.WIPE_ON_DEVICE_REMOVAL, wipeOnRootedDevice = BuildConfig.WIPE_ON_ROOTED_DEVICE, - isWebSocketEnabledByDefault = isWebsocketEnabledByDefault(context), + isWebSocketEnabledByDefault = isWebsocketEnabledByDefault( + context, + managedConfigurationsManager.persistentWebSocketEnforcedByMDM.value + ), certPinningConfig = BuildConfig.CERTIFICATE_PINNING_CONFIG, maxRemoteSearchResultCount = BuildConfig.MAX_REMOTE_SEARCH_RESULT_COUNT, limitTeamMembersFetchDuringSlowSync = BuildConfig.LIMIT_TEAM_MEMBERS_FETCH_DURING_SLOW_SYNC, diff --git a/app/src/main/kotlin/com/wire/android/emm/ManagedConfigurationsReceiver.kt b/app/src/main/kotlin/com/wire/android/emm/ManagedConfigurationsReceiver.kt index 40c865febcd..9ae901a5eec 100644 --- a/app/src/main/kotlin/com/wire/android/emm/ManagedConfigurationsReceiver.kt +++ b/app/src/main/kotlin/com/wire/android/emm/ManagedConfigurationsReceiver.kt @@ -21,8 +21,12 @@ import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import com.wire.android.appLogger +import com.wire.android.di.KaliumCoreLogic +import com.wire.android.feature.StartPersistentWebsocketIfNecessaryUseCase import com.wire.android.util.EMPTY import com.wire.android.util.dispatchers.DispatcherProvider +import com.wire.kalium.logic.CoreLogic +import dagger.Lazy import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch @@ -33,6 +37,8 @@ import javax.inject.Singleton class ManagedConfigurationsReceiver @Inject constructor( private val managedConfigurationsManager: ManagedConfigurationsManager, private val managedConfigurationsReporter: ManagedConfigurationsReporter, + @KaliumCoreLogic private val coreLogic: Lazy, + private val startPersistentWebsocketIfNecessary: StartPersistentWebsocketIfNecessaryUseCase, dispatcher: DispatcherProvider ) : BroadcastReceiver() { @@ -105,8 +111,18 @@ class ManagedConfigurationsReceiver @Inject constructor( } private suspend fun updatePersistentWebSocketConfig() { + val wasEnforced = managedConfigurationsManager.persistentWebSocketEnforcedByMDM.value managedConfigurationsManager.refreshPersistentWebSocketConfig() val isEnforced = managedConfigurationsManager.persistentWebSocketEnforcedByMDM.value + + // Only bulk update when MDM enforcement turns ON + if (!wasEnforced && isEnforced) { + coreLogic.get().getGlobalScope().setAllPersistentWebSocketEnabled(true) + } + + // Trigger service start/stop based on current state + startPersistentWebsocketIfNecessary() + managedConfigurationsReporter.reportAppliedState( key = ManagedConfigurationsKeys.KEEP_WEBSOCKET_CONNECTION.asKey(), message = if (isEnforced) "Persistent WebSocket enforced" else "Persistent WebSocket not enforced", diff --git a/app/src/main/kotlin/com/wire/android/util/WebsocketHelper.kt b/app/src/main/kotlin/com/wire/android/util/WebsocketHelper.kt index e89f83d3a7f..fc1d27cd01e 100644 --- a/app/src/main/kotlin/com/wire/android/util/WebsocketHelper.kt +++ b/app/src/main/kotlin/com/wire/android/util/WebsocketHelper.kt @@ -22,8 +22,16 @@ import com.wire.android.BuildConfig import com.wire.android.util.extension.isGoogleServicesAvailable /** - * If [BuildConfig.WEBSOCKET_ENABLED_BY_DEFAULT] is true, the websocket should be enabled by default always. - * Otherwise, it should be enabled by default only if Google Play Services are not available. + * Determines if websocket should be enabled by default. + * + * Returns true if: + * - MDM enforces persistent websocket, OR + * - [BuildConfig.WEBSOCKET_ENABLED_BY_DEFAULT] is true, OR + * - Google Play Services are not available */ -fun isWebsocketEnabledByDefault(context: Context) = - BuildConfig.WEBSOCKET_ENABLED_BY_DEFAULT || !context.isGoogleServicesAvailable() +fun isWebsocketEnabledByDefault( + context: Context, + persistentWebSocketEnforcedByMDM: Boolean = false +) = persistentWebSocketEnforcedByMDM || + BuildConfig.WEBSOCKET_ENABLED_BY_DEFAULT || + !context.isGoogleServicesAvailable() diff --git a/app/src/test/kotlin/com/wire/android/emm/ManagedConfigurationsReceiverTest.kt b/app/src/test/kotlin/com/wire/android/emm/ManagedConfigurationsReceiverTest.kt index 734e6651a4d..5c2bd38ce85 100644 --- a/app/src/test/kotlin/com/wire/android/emm/ManagedConfigurationsReceiverTest.kt +++ b/app/src/test/kotlin/com/wire/android/emm/ManagedConfigurationsReceiverTest.kt @@ -22,7 +22,10 @@ import android.content.Context import android.content.Intent import androidx.test.core.app.ApplicationProvider import com.wire.android.config.TestDispatcherProvider +import com.wire.android.feature.StartPersistentWebsocketIfNecessaryUseCase import com.wire.android.util.EMPTY +import com.wire.kalium.logic.CoreLogic +import com.wire.kalium.logic.GlobalKaliumScope import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every @@ -187,17 +190,65 @@ class ManagedConfigurationsReceiverTest { coVerify(exactly = 0) { arrangement.managedConfigurationsManager.refreshSSOCodeConfig() } } + @Test + fun `given websocket enforcement turns ON, when onReceive is called, then bulk enable persistent websocket and trigger service`() = + runTest { + val (arrangement, receiver) = Arrangement() + .withIntent(Intent.ACTION_APPLICATION_RESTRICTIONS_CHANGED) + .withPersistentWebSocketTransition(from = false, to = true) + .arrange() + + receiver.onReceive(arrangement.context, arrangement.intent) + advanceUntilIdle() + + coVerify(exactly = 1) { arrangement.globalScope.setAllPersistentWebSocketEnabled(true) } + coVerify(exactly = 1) { arrangement.startPersistentWebsocketIfNecessary.invoke() } + coVerify(exactly = 1) { + arrangement.managedConfigurationsReporter.reportAppliedState( + eq(ManagedConfigurationsKeys.KEEP_WEBSOCKET_CONNECTION.asKey()), + eq("Persistent WebSocket enforced"), + eq("true") + ) + } + } + + @Test + fun `given websocket enforcement turns OFF, when onReceive is called, then do not bulk enable and trigger service`() = + runTest { + val (arrangement, receiver) = Arrangement() + .withIntent(Intent.ACTION_APPLICATION_RESTRICTIONS_CHANGED) + .withPersistentWebSocketTransition(from = true, to = false) + .arrange() + + receiver.onReceive(arrangement.context, arrangement.intent) + advanceUntilIdle() + + coVerify(exactly = 0) { arrangement.globalScope.setAllPersistentWebSocketEnabled(true) } + coVerify(exactly = 1) { arrangement.startPersistentWebsocketIfNecessary.invoke() } + coVerify(exactly = 1) { + arrangement.managedConfigurationsReporter.reportAppliedState( + eq(ManagedConfigurationsKeys.KEEP_WEBSOCKET_CONNECTION.asKey()), + eq("Persistent WebSocket not enforced"), + eq("false") + ) + } + } + private class Arrangement { val context: Context = ApplicationProvider.getApplicationContext() val managedConfigurationsManager: ManagedConfigurationsManager = mockk(relaxed = true) val managedConfigurationsReporter: ManagedConfigurationsReporter = mockk(relaxed = true) + val coreLogic: CoreLogic = mockk(relaxed = true) + val globalScope: GlobalKaliumScope = mockk(relaxed = true) + val startPersistentWebsocketIfNecessary: StartPersistentWebsocketIfNecessaryUseCase = mockk(relaxed = true) private val dispatchers = TestDispatcherProvider() private val persistentWebSocketEnforcedFlow = MutableStateFlow(false) lateinit var intent: Intent init { every { managedConfigurationsManager.persistentWebSocketEnforcedByMDM } returns persistentWebSocketEnforcedFlow + every { coreLogic.getGlobalScope() } returns globalScope } fun withIntent(action: String?) = apply { @@ -216,9 +267,18 @@ class ManagedConfigurationsReceiverTest { persistentWebSocketEnforcedFlow.value = enforced } + fun withPersistentWebSocketTransition(from: Boolean, to: Boolean) = apply { + persistentWebSocketEnforcedFlow.value = from + coEvery { managedConfigurationsManager.refreshPersistentWebSocketConfig() } answers { + persistentWebSocketEnforcedFlow.value = to + } + } + fun arrange() = this to ManagedConfigurationsReceiver( managedConfigurationsManager, managedConfigurationsReporter, + lazy { coreLogic }, + startPersistentWebsocketIfNecessary, dispatchers ) } diff --git a/app/src/test/kotlin/com/wire/android/util/WebsocketHelperTest.kt b/app/src/test/kotlin/com/wire/android/util/WebsocketHelperTest.kt new file mode 100644 index 00000000000..2325befd87b --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/util/WebsocketHelperTest.kt @@ -0,0 +1,49 @@ +package com.wire.android.util + +import android.app.Application +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import com.wire.android.BuildConfig +import io.mockk.coEvery +import io.mockk.mockkStatic +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +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 WebsocketHelperTest { + + private val context: Context = ApplicationProvider.getApplicationContext() + + @Test + fun `when MDM enforces persistent websocket, isWebsocketEnabledByDefault returns true`() { + mockkStatic("com.wire.android.util.extension.GoogleServicesKt") + coEvery { context.isGoogleServicesAvailable() } returns true + + val result = isWebsocketEnabledByDefault(context, persistentWebSocketEnforcedByMDM = true) + assertTrue(result) + } + + @Test + fun `when GMS not available and MDM not enforced, isWebsocketEnabledByDefault returns true`() { + mockkStatic("com.wire.android.util.extension.GoogleServicesKt") + coEvery { context.isGoogleServicesAvailable() } returns false + + val result = isWebsocketEnabledByDefault(context, persistentWebSocketEnforcedByMDM = false) + assertTrue(result) + } + + @Test + fun `when GMS available and MDM not enforced, isWebsocketEnabledByDefault matches BuildConfig flag`() { + mockkStatic("com.wire.android.util.extension.GoogleServicesKt") + coEvery { context.isGoogleServicesAvailable() } returns true + + val result = isWebsocketEnabledByDefault(context, persistentWebSocketEnforcedByMDM = false) + assertEquals(BuildConfig.WEBSOCKET_ENABLED_BY_DEFAULT, result) + } +} + diff --git a/kalium b/kalium index 3e25b914b76..30091ea067f 160000 --- a/kalium +++ b/kalium @@ -1 +1 @@ -Subproject commit 3e25b914b761cc04d9bff8ce2b8ebb59fd152357 +Subproject commit 30091ea067f16317f3234371fb51c4c8f998913b From 65866e9eb2014b3c63fe1d99d4fb9b69d9bcf143 Mon Sep 17 00:00:00 2001 From: Mohamad Jaara Date: Tue, 3 Feb 2026 14:25:43 +0100 Subject: [PATCH 5/5] feat: ensure MDM-enforced persistent WebSocket state is applied on app start --- app/src/main/kotlin/com/wire/android/ui/WireActivity.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/kotlin/com/wire/android/ui/WireActivity.kt b/app/src/main/kotlin/com/wire/android/ui/WireActivity.kt index 3676d2dcc79..7e1964df887 100644 --- a/app/src/main/kotlin/com/wire/android/ui/WireActivity.kt +++ b/app/src/main/kotlin/com/wire/android/ui/WireActivity.kt @@ -224,6 +224,7 @@ class WireActivity : AppCompatActivity() { lifecycleScope.launch(Dispatchers.IO) { managedConfigurationsManager.refreshServerConfig() managedConfigurationsManager.refreshSSOCodeConfig() + managedConfigurationsManager.refreshPersistentWebSocketConfig() } } }