From dc2ccd507d8884b85e4230e008dc5f4657cc7e56 Mon Sep 17 00:00:00 2001 From: Mohamad Jaara Date: Mon, 2 Feb 2026 11:00:31 +0100 Subject: [PATCH 1/7] 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 6c229c4f0d4..0b402dd7382 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 0ac7c385b7d..30091ea067f 160000 --- a/kalium +++ b/kalium @@ -1 +1 @@ -Subproject commit 0ac7c385b7dbbf9334125ff4eae9636d212134ad +Subproject commit 30091ea067f16317f3234371fb51c4c8f998913b From 66da6e425793a947ce803a885f171d7dda260333 Mon Sep 17 00:00:00 2001 From: Mohamad Jaara Date: Tue, 3 Feb 2026 10:05:23 +0100 Subject: [PATCH 2/7] 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 f1dfcbc1918e67c974cf226be59bf7669820a907 Mon Sep 17 00:00:00 2001 From: Mohamad Jaara Date: Tue, 3 Feb 2026 10:32:04 +0100 Subject: [PATCH 3/7] 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 25d939499eccda343baad99fba93c7d720044687 Mon Sep 17 00:00:00 2001 From: Mohamad Jaara Date: Tue, 3 Feb 2026 12:40:44 +0100 Subject: [PATCH 4/7] feat: implement bulk update for persistent WebSocket status via MDM --- .../emm/ManagedConfigurationsReceiver.kt | 16 +++++ .../com/wire/android/util/WebsocketHelper.kt | 16 +++-- .../emm/ManagedConfigurationsReceiverTest.kt | 60 +++++++++++++++++++ .../wire/android/util/WebsocketHelperTest.kt | 49 +++++++++++++++ 4 files changed, 137 insertions(+), 4 deletions(-) create mode 100644 app/src/test/kotlin/com/wire/android/util/WebsocketHelperTest.kt 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) + } +} + From df347916f74c916bd108c461b3d59dafcd8b3650 Mon Sep 17 00:00:00 2001 From: Mohamad Jaara Date: Tue, 3 Feb 2026 14:25:43 +0100 Subject: [PATCH 5/7] 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() } } } From 3e8d70a67b5abbb1c468a423921e920697b55f72 Mon Sep 17 00:00:00 2001 From: Mohamad Jaara Date: Tue, 3 Feb 2026 14:25:43 +0100 Subject: [PATCH 6/7] feat: ensure MDM-enforced persistent WebSocket state is applied on app start --- .../wire/android/datastore/GlobalDataStore.kt | 8 +++ .../com/wire/android/di/CoreLogicModule.kt | 10 +++- .../android/di/ManagedConfigurationsModule.kt | 6 +- .../emm/ManagedConfigurationsManager.kt | 16 +++-- .../emm/ManagedConfigurationsReceiver.kt | 3 +- .../com/wire/android/ui/WireActivity.kt | 2 +- .../wire/android/ui/WireActivityViewModel.kt | 30 +++++++--- .../emm/ManagedConfigurationsManagerTest.kt | 13 ++-- .../emm/ManagedConfigurationsReceiverTest.kt | 1 + .../android/ui/WireActivityViewModelTest.kt | 60 +++++++++++++++++++ .../wire/android/util/WebsocketHelperTest.kt | 2 +- 11 files changed, 127 insertions(+), 24 deletions(-) diff --git a/app/src/main/kotlin/com/wire/android/datastore/GlobalDataStore.kt b/app/src/main/kotlin/com/wire/android/datastore/GlobalDataStore.kt index 08aea8cde3d..b81e777bab6 100644 --- a/app/src/main/kotlin/com/wire/android/datastore/GlobalDataStore.kt +++ b/app/src/main/kotlin/com/wire/android/datastore/GlobalDataStore.kt @@ -56,6 +56,7 @@ class GlobalDataStore @Inject constructor(@ApplicationContext private val contex private val ENTER_TO_SENT = booleanPreferencesKey("enter_to_sent") private val ANONYMOUS_REGISTRATION_TRACK_ID = stringPreferencesKey("anonymous_registration_track_id") private val IS_ANONYMOUS_REGISTRATION_ENABLED = booleanPreferencesKey("is_anonymous_registration_enabled") + private val PERSISTENT_WEBSOCKET_ENFORCED_BY_MDM = booleanPreferencesKey("persistent_websocket_enforced_by_mdm") val APP_THEME_OPTION = stringPreferencesKey("app_theme_option") val RECORD_AUDIO_EFFECTS_CHECKBOX = booleanPreferencesKey("record_audio_effects_checkbox") @@ -205,4 +206,11 @@ class GlobalDataStore @Inject constructor(@ApplicationContext private val contex suspend fun setEnterToSend(enabled: Boolean) { context.dataStore.edit { it[ENTER_TO_SENT] = enabled } } + + fun isPersistentWebSocketEnforcedByMDM(): Flow = + getBooleanPreference(PERSISTENT_WEBSOCKET_ENFORCED_BY_MDM, false) + + suspend fun setPersistentWebSocketEnforcedByMDM(enforced: Boolean) { + context.dataStore.edit { it[PERSISTENT_WEBSOCKET_ENFORCED_BY_MDM] = enforced } + } } diff --git a/app/src/main/kotlin/com/wire/android/di/CoreLogicModule.kt b/app/src/main/kotlin/com/wire/android/di/CoreLogicModule.kt index c2b8c16b74e..172392cd1a2 100644 --- a/app/src/main/kotlin/com/wire/android/di/CoreLogicModule.kt +++ b/app/src/main/kotlin/com/wire/android/di/CoreLogicModule.kt @@ -21,6 +21,7 @@ package com.wire.android.di import android.content.Context import androidx.work.WorkManager import com.wire.android.datastore.UserDataStoreProvider +import com.wire.android.emm.ManagedConfigurationsManager import com.wire.android.util.ImageUtil import com.wire.android.util.UserAgentProvider import com.wire.android.util.isWebsocketEnabledByDefault @@ -152,8 +153,13 @@ class CoreLogicModule { @DefaultWebSocketEnabledByDefault @Provides - fun provideDefaultWebSocketEnabledByDefault(@ApplicationContext context: Context): Boolean = - isWebsocketEnabledByDefault(context) + fun provideDefaultWebSocketEnabledByDefault( + @ApplicationContext context: Context, + managedConfigurationsManager: ManagedConfigurationsManager + ): Boolean = isWebsocketEnabledByDefault( + context, + managedConfigurationsManager.persistentWebSocketEnforcedByMDM.value + ) } @Module 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..739eb6b12b9 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,7 @@ package com.wire.android.di import android.content.Context import com.wire.android.BuildConfig import com.wire.android.config.ServerConfigProvider +import com.wire.android.datastore.GlobalDataStore import com.wire.android.emm.ManagedConfigurationsManager import com.wire.android.emm.ManagedConfigurationsManagerImpl import com.wire.android.util.EMPTY @@ -46,9 +47,10 @@ class ManagedConfigurationsModule { fun provideManagedConfigurationsRepository( @ApplicationContext context: Context, dispatcherProvider: DispatcherProvider, - serverConfigProvider: ServerConfigProvider + serverConfigProvider: ServerConfigProvider, + globalDataStore: GlobalDataStore ): ManagedConfigurationsManager { - return ManagedConfigurationsManagerImpl(context, dispatcherProvider, serverConfigProvider) + return ManagedConfigurationsManagerImpl(context, dispatcherProvider, serverConfigProvider, globalDataStore) } @Provides 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 d67804ae1a7..f5ade173a64 100644 --- a/app/src/main/kotlin/com/wire/android/emm/ManagedConfigurationsManager.kt +++ b/app/src/main/kotlin/com/wire/android/emm/ManagedConfigurationsManager.kt @@ -21,12 +21,15 @@ import android.content.Context import android.content.RestrictionsManager import com.wire.android.appLogger import com.wire.android.config.ServerConfigProvider +import com.wire.android.datastore.GlobalDataStore 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.flow.first +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json import java.util.concurrent.atomic.AtomicReference @@ -81,13 +84,14 @@ interface ManagedConfigurationsManager { * Refresh the persistent WebSocket configuration from managed restrictions. * This should be called when the app starts or when broadcast receiver triggers. */ - suspend fun refreshPersistentWebSocketConfig() + suspend fun refreshPersistentWebSocketConfig(): Boolean } internal class ManagedConfigurationsManagerImpl( private val context: Context, private val dispatchers: DispatcherProvider, private val serverConfigProvider: ServerConfigProvider, + private val globalDataStore: GlobalDataStore, ) : ManagedConfigurationsManager { private val json: Json = Json { ignoreUnknownKeys = true } @@ -98,7 +102,9 @@ internal class ManagedConfigurationsManagerImpl( private val _currentServerConfig = AtomicReference(null) private val _currentSSOCodeConfig = AtomicReference(String.EMPTY) - private val _persistentWebSocketEnforcedByMDM = MutableStateFlow(false) + private val _persistentWebSocketEnforcedByMDM by lazy { + MutableStateFlow(runBlocking { globalDataStore.isPersistentWebSocketEnforcedByMDM().first() }) + } override val currentServerConfig: ServerConfig.Links get() = _currentServerConfig.get() ?: serverConfigProvider.getDefaultServerConfig() @@ -138,8 +144,8 @@ internal class ManagedConfigurationsManagerImpl( managedSSOCodeConfig } - override suspend fun refreshPersistentWebSocketConfig() { - withContext(dispatchers.io()) { + override suspend fun refreshPersistentWebSocketConfig(): Boolean { + return withContext(dispatchers.io()) { val restrictions = restrictionsManager.applicationRestrictions val isEnforced = if (restrictions == null || restrictions.isEmpty) { false @@ -150,7 +156,9 @@ internal class ManagedConfigurationsManagerImpl( ) } _persistentWebSocketEnforcedByMDM.value = isEnforced + globalDataStore.setPersistentWebSocketEnforcedByMDM(isEnforced) logger.i("Persistent WebSocket enforced by MDM refreshed to: $isEnforced") + isEnforced } } 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 9ae901a5eec..3ba18946254 100644 --- a/app/src/main/kotlin/com/wire/android/emm/ManagedConfigurationsReceiver.kt +++ b/app/src/main/kotlin/com/wire/android/emm/ManagedConfigurationsReceiver.kt @@ -112,8 +112,7 @@ class ManagedConfigurationsReceiver @Inject constructor( private suspend fun updatePersistentWebSocketConfig() { val wasEnforced = managedConfigurationsManager.persistentWebSocketEnforcedByMDM.value - managedConfigurationsManager.refreshPersistentWebSocketConfig() - val isEnforced = managedConfigurationsManager.persistentWebSocketEnforcedByMDM.value + val isEnforced = managedConfigurationsManager.refreshPersistentWebSocketConfig() // Only bulk update when MDM enforcement turns ON if (!wasEnforced && isEnforced) { 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 7e1964df887..8683b1267a2 100644 --- a/app/src/main/kotlin/com/wire/android/ui/WireActivity.kt +++ b/app/src/main/kotlin/com/wire/android/ui/WireActivity.kt @@ -224,8 +224,8 @@ class WireActivity : AppCompatActivity() { lifecycleScope.launch(Dispatchers.IO) { managedConfigurationsManager.refreshServerConfig() managedConfigurationsManager.refreshSSOCodeConfig() - managedConfigurationsManager.refreshPersistentWebSocketConfig() } + viewModel.applyPersistentWebSocketConfigFromMDM() } } diff --git a/app/src/main/kotlin/com/wire/android/ui/WireActivityViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/WireActivityViewModel.kt index a6486f4b7b7..9ae569c7906 100644 --- a/app/src/main/kotlin/com/wire/android/ui/WireActivityViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/WireActivityViewModel.kt @@ -56,6 +56,7 @@ import com.wire.android.util.deeplink.DeepLinkResult import com.wire.android.util.deeplink.LoginType import com.wire.android.util.dispatchers.DispatcherProvider import com.wire.android.util.ui.UIText +import com.wire.android.emm.ManagedConfigurationsManager import com.wire.android.workmanager.worker.cancelPeriodicPersistentWebsocketCheckWorker import com.wire.android.workmanager.worker.enqueuePeriodicPersistentWebsocketCheckWorker import com.wire.kalium.logic.CoreLogic @@ -96,6 +97,7 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.flow.stateIn @@ -132,6 +134,7 @@ class WireActivityViewModel @Inject constructor( private val isProfileQRCodeEnabledFactory: IsProfileQRCodeEnabledUseCaseProvider.Factory, private val observeSelfUserFactory: ObserveSelfUserUseCaseProvider.Factory, private val monitorSyncWorkUseCase: MonitorSyncWorkUseCase, + private val managedConfigurationsManager: ManagedConfigurationsManager, ) : ActionsViewModel() { var globalAppState: GlobalAppState by mutableStateOf(GlobalAppState()) @@ -512,6 +515,16 @@ class WireActivityViewModel @Inject constructor( globalAppState = globalAppState.copy(maxAccountDialog = false) } + fun applyPersistentWebSocketConfigFromMDM() { + viewModelScope.launch(dispatchers.io()) { + val wasEnforced = managedConfigurationsManager.persistentWebSocketEnforcedByMDM.value + val isEnforced = managedConfigurationsManager.refreshPersistentWebSocketConfig() + if (!wasEnforced && isEnforced) { + coreLogic.get().getGlobalScope().setAllPersistentWebSocketEnabled(true) + } + } + } + fun observePersistentConnectionStatus() { viewModelScope.launch { coreLogic.get().getGlobalScope().observePersistentWebSocketConnectionStatus() @@ -522,15 +535,16 @@ class WireActivityViewModel @Inject constructor( } is ObservePersistentWebSocketConnectionStatusUseCase.Result.Success -> { - result.persistentWebSocketStatusListFlow.collect { statuses -> - - if (statuses.any { it.isPersistentWebSocketEnabled }) { - if (!servicesManager.get() - .isPersistentWebSocketServiceRunning() - ) { + combine( + result.persistentWebSocketStatusListFlow, + managedConfigurationsManager.persistentWebSocketEnforcedByMDM + ) { statuses, mdmEnforced -> + mdmEnforced || statuses.any { it.isPersistentWebSocketEnabled } + }.collect { shouldBeRunning -> + if (shouldBeRunning) { + if (!servicesManager.get().isPersistentWebSocketServiceRunning()) { servicesManager.get().startPersistentWebSocketService() - workManager.get() - .enqueuePeriodicPersistentWebsocketCheckWorker() + workManager.get().enqueuePeriodicPersistentWebsocketCheckWorker() } } else { servicesManager.get().stopPersistentWebSocketService() 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 ba18717072b..13004874a9c 100644 --- a/app/src/test/kotlin/com/wire/android/emm/ManagedConfigurationsManagerTest.kt +++ b/app/src/test/kotlin/com/wire/android/emm/ManagedConfigurationsManagerTest.kt @@ -7,6 +7,7 @@ import android.os.Bundle import androidx.test.core.app.ApplicationProvider import com.wire.android.config.ServerConfigProvider import com.wire.android.config.TestDispatcherProvider +import com.wire.android.datastore.GlobalDataStore import com.wire.android.util.EMPTY import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals @@ -132,7 +133,8 @@ class ManagedConfigurationsManagerTest { .withBooleanRestrictions(mapOf(ManagedConfigurationsKeys.KEEP_WEBSOCKET_CONNECTION.asKey() to true)) .arrange() - manager.refreshPersistentWebSocketConfig() + val result = manager.refreshPersistentWebSocketConfig() + assertEquals(true, result) assertEquals(true, manager.persistentWebSocketEnforcedByMDM.value) } @@ -142,7 +144,8 @@ class ManagedConfigurationsManagerTest { .withBooleanRestrictions(mapOf(ManagedConfigurationsKeys.KEEP_WEBSOCKET_CONNECTION.asKey() to false)) .arrange() - manager.refreshPersistentWebSocketConfig() + val result = manager.refreshPersistentWebSocketConfig() + assertEquals(false, result) assertEquals(false, manager.persistentWebSocketEnforcedByMDM.value) } @@ -152,7 +155,8 @@ class ManagedConfigurationsManagerTest { .withRestrictions(emptyMap()) .arrange() - manager.refreshPersistentWebSocketConfig() + val result = manager.refreshPersistentWebSocketConfig() + assertEquals(false, result) assertEquals(false, manager.persistentWebSocketEnforcedByMDM.value) } @@ -189,7 +193,8 @@ class ManagedConfigurationsManagerTest { fun arrange() = this to ManagedConfigurationsManagerImpl( context = context, serverConfigProvider = ServerConfigProvider(), - dispatchers = TestDispatcherProvider() + dispatchers = TestDispatcherProvider(), + globalDataStore = GlobalDataStore(context) ) } 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 5c2bd38ce85..4e916b74df9 100644 --- a/app/src/test/kotlin/com/wire/android/emm/ManagedConfigurationsReceiverTest.kt +++ b/app/src/test/kotlin/com/wire/android/emm/ManagedConfigurationsReceiverTest.kt @@ -271,6 +271,7 @@ class ManagedConfigurationsReceiverTest { persistentWebSocketEnforcedFlow.value = from coEvery { managedConfigurationsManager.refreshPersistentWebSocketConfig() } answers { persistentWebSocketEnforcedFlow.value = to + to } } diff --git a/app/src/test/kotlin/com/wire/android/ui/WireActivityViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/WireActivityViewModelTest.kt index acd3d11e383..4d3580cfa9b 100644 --- a/app/src/test/kotlin/com/wire/android/ui/WireActivityViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/WireActivityViewModelTest.kt @@ -31,6 +31,7 @@ import com.wire.android.config.TestDispatcherProvider import com.wire.android.config.mockUri import com.wire.android.datastore.GlobalDataStore import com.wire.android.di.IsProfileQRCodeEnabledUseCaseProvider +import com.wire.android.emm.ManagedConfigurationsManager import com.wire.android.di.ObserveIfE2EIRequiredDuringLoginUseCaseProvider import com.wire.android.di.ObserveScreenshotCensoringConfigUseCaseProvider import com.wire.android.di.ObserveSelfUserUseCaseProvider @@ -526,6 +527,56 @@ class WireActivityViewModelTest { coVerify(exactly = 0) { arrangement.servicesManager.stopPersistentWebSocketService() } } + @Test + fun `given MDM enforced and no user enabled in DB, then service should be started`() = runTest { + val statuses = listOf( + PersistentWebSocketStatus(TestUser.SELF_USER.id, false), + ) + val (arrangement, viewModel) = Arrangement() + .withPersistentWebSocketConnectionStatuses(statuses) + .withIsPersistentWebSocketServiceRunning(false) + .withPersistentWebSocketEnforcedByMDM(true) + .arrange() + + viewModel.observePersistentConnectionStatus() + + coVerify(exactly = 1) { arrangement.servicesManager.startPersistentWebSocketService() } + coVerify(exactly = 0) { arrangement.servicesManager.stopPersistentWebSocketService() } + } + + @Test + fun `given MDM not enforced and no user enabled in DB, then service should be stopped`() = runTest { + val statuses = listOf( + PersistentWebSocketStatus(TestUser.SELF_USER.id, false), + ) + val (arrangement, viewModel) = Arrangement() + .withPersistentWebSocketConnectionStatuses(statuses) + .withPersistentWebSocketEnforcedByMDM(false) + .arrange() + + viewModel.observePersistentConnectionStatus() + + coVerify(exactly = 0) { arrangement.servicesManager.startPersistentWebSocketService() } + coVerify(exactly = 1) { arrangement.servicesManager.stopPersistentWebSocketService() } + } + + @Test + fun `given MDM not enforced but some user enabled in DB, then service should be started`() = runTest { + val statuses = listOf( + PersistentWebSocketStatus(TestUser.SELF_USER.id, true), + ) + val (arrangement, viewModel) = Arrangement() + .withPersistentWebSocketConnectionStatuses(statuses) + .withIsPersistentWebSocketServiceRunning(false) + .withPersistentWebSocketEnforcedByMDM(false) + .arrange() + + viewModel.observePersistentConnectionStatus() + + coVerify(exactly = 1) { arrangement.servicesManager.startPersistentWebSocketService() } + coVerify(exactly = 0) { arrangement.servicesManager.stopPersistentWebSocketService() } + } + @Test fun `given newClient is registered for the current user, then should show the NewClient dialog`() = runTest { val (_, viewModel) = Arrangement() @@ -794,6 +845,7 @@ class WireActivityViewModelTest { val observeSelfUserUseCase = mockk() every { observeSelfUserFactory.create(any()).observeSelfUser } returns observeSelfUserUseCase coEvery { observeSelfUserUseCase() } returns flowOf(SELF_USER) + every { managedConfigurationsManager.persistentWebSocketEnforcedByMDM } returns persistentWebSocketEnforcedByMDMFlow } @MockK @@ -865,6 +917,9 @@ class WireActivityViewModelTest { @MockK lateinit var monitorSyncWorkUseCase: MonitorSyncWorkUseCase + val managedConfigurationsManager: ManagedConfigurationsManager = mockk(relaxed = true) + private val persistentWebSocketEnforcedByMDMFlow = MutableStateFlow(false) + private val viewModel by lazy { WireActivityViewModel( coreLogic = { coreLogic }, @@ -888,6 +943,7 @@ class WireActivityViewModelTest { isProfileQRCodeEnabledFactory = isProfileQRCodeEnabledFactory, observeSelfUserFactory = observeSelfUserFactory, monitorSyncWorkUseCase = monitorSyncWorkUseCase, + managedConfigurationsManager = managedConfigurationsManager, ) } @@ -967,6 +1023,10 @@ class WireActivityViewModelTest { every { servicesManager.isPersistentWebSocketServiceRunning() } returns isRunning } + fun withPersistentWebSocketEnforcedByMDM(enforced: Boolean): Arrangement = apply { + persistentWebSocketEnforcedByMDMFlow.value = enforced + } + fun withNewClient(result: NewClientResult) = apply { coEvery { observeNewClients() } returns flowOf(result) } diff --git a/app/src/test/kotlin/com/wire/android/util/WebsocketHelperTest.kt b/app/src/test/kotlin/com/wire/android/util/WebsocketHelperTest.kt index 2325befd87b..7df0a248b70 100644 --- a/app/src/test/kotlin/com/wire/android/util/WebsocketHelperTest.kt +++ b/app/src/test/kotlin/com/wire/android/util/WebsocketHelperTest.kt @@ -4,6 +4,7 @@ import android.app.Application import android.content.Context import androidx.test.core.app.ApplicationProvider import com.wire.android.BuildConfig +import com.wire.android.util.extension.isGoogleServicesAvailable import io.mockk.coEvery import io.mockk.mockkStatic import org.junit.Assert.assertEquals @@ -46,4 +47,3 @@ class WebsocketHelperTest { assertEquals(BuildConfig.WEBSOCKET_ENABLED_BY_DEFAULT, result) } } - From 7f385724e30315b523ce9bf9e585ad0e28e5a5c3 Mon Sep 17 00:00:00 2001 From: "sergei.bakhtiarov" Date: Tue, 10 Feb 2026 10:08:12 +0100 Subject: [PATCH 7/7] fixing build issue --- .../android/emm/ManagedConfigurationsReceiverTest.kt | 10 +++++----- .../com/wire/android/ui/WireActivityViewModelTest.kt | 6 +++--- 2 files changed, 8 insertions(+), 8 deletions(-) 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 4e916b74df9..fff120671bb 100644 --- a/app/src/test/kotlin/com/wire/android/emm/ManagedConfigurationsReceiverTest.kt +++ b/app/src/test/kotlin/com/wire/android/emm/ManagedConfigurationsReceiverTest.kt @@ -276,11 +276,11 @@ class ManagedConfigurationsReceiverTest { } fun arrange() = this to ManagedConfigurationsReceiver( - managedConfigurationsManager, - managedConfigurationsReporter, - lazy { coreLogic }, - startPersistentWebsocketIfNecessary, - dispatchers + managedConfigurationsManager = managedConfigurationsManager, + managedConfigurationsReporter = managedConfigurationsReporter, + coreLogic = { coreLogic }, + startPersistentWebsocketIfNecessary = startPersistentWebsocketIfNecessary, + dispatcher = dispatchers ) } } diff --git a/app/src/test/kotlin/com/wire/android/ui/WireActivityViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/WireActivityViewModelTest.kt index 4d3580cfa9b..b8013918947 100644 --- a/app/src/test/kotlin/com/wire/android/ui/WireActivityViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/WireActivityViewModelTest.kt @@ -816,6 +816,9 @@ class WireActivityViewModelTest { private class Arrangement { + val managedConfigurationsManager: ManagedConfigurationsManager = mockk(relaxed = true) + private val persistentWebSocketEnforcedByMDMFlow = MutableStateFlow(false) + init { // Tests setup MockKAnnotations.init(this, relaxUnitFun = true) @@ -917,9 +920,6 @@ class WireActivityViewModelTest { @MockK lateinit var monitorSyncWorkUseCase: MonitorSyncWorkUseCase - val managedConfigurationsManager: ManagedConfigurationsManager = mockk(relaxed = true) - private val persistentWebSocketEnforcedByMDMFlow = MutableStateFlow(false) - private val viewModel by lazy { WireActivityViewModel( coreLogic = { coreLogic },