From 2565fb23949a4d57fbac8537f4c4d3cfb8709395 Mon Sep 17 00:00:00 2001 From: Mohamad Jaara <9083456+MohamadJaara@users.noreply.github.com> Date: Wed, 12 Aug 2026 12:33:38 +0200 Subject: [PATCH] feat(conversation): add manual MLS migration (WPB-27446) --- app/build.gradle.kts | 1 + .../GroupConversationDetailsViewModel.kt | 144 ++++++++++- .../options/GroupConversationOptions.kt | 60 ++++- .../options/GroupConversationOptionsState.kt | 5 +- app/src/main/res/values/strings.xml | 6 + .../details/GroupDetailsViewModelTest.kt | 233 ++++++++++++++++++ .../com/wire/android/model/Clickable.kt | 1 + .../com/wire/android/ui/common/Extensions.kt | 6 +- 8 files changed, 448 insertions(+), 8 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index faab97578ab..18164f98971 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -197,6 +197,7 @@ if (!project.hasProperty("skip.aboutlibraries")) { dependencies { implementation("com.wire.kalium:kalium-logic") + implementation("com.wire.kalium:kalium-network") implementation("com.wire.kalium:kalium-util") implementation("com.wire.kalium:kalium-cells") implementation("com.wire.kalium:kalium-core-libsodium") diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/GroupConversationDetailsViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/GroupConversationDetailsViewModel.kt index 5a6a8c61ba3..06b3ee3cdd7 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/GroupConversationDetailsViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/GroupConversationDetailsViewModel.kt @@ -19,6 +19,7 @@ package com.wire.android.ui.home.conversations.details import androidx.lifecycle.viewModelScope +import com.wire.android.R import com.wire.android.appLogger import com.wire.android.ui.common.ActionsViewModel import com.wire.android.ui.home.conversations.details.options.GroupConversationOptionsState @@ -31,8 +32,11 @@ import com.wire.android.ui.home.newconversation.channelaccess.toUiEnum import com.wire.android.util.AppsUtil import com.wire.android.util.dispatchers.DispatcherProvider import com.wire.android.util.ui.UIText +import com.wire.kalium.common.error.CoreFailure +import com.wire.kalium.common.error.NetworkFailure import com.wire.kalium.logic.data.conversation.Conversation import com.wire.kalium.logic.data.conversation.ConversationDetails +import com.wire.kalium.logic.data.featureConfig.Status import com.wire.kalium.logic.data.id.QualifiedID import com.wire.kalium.logic.data.user.type.isExternal import com.wire.kalium.logic.data.user.type.isFederated @@ -40,14 +44,19 @@ import com.wire.kalium.logic.data.user.type.isRegularTeamMember import com.wire.kalium.logic.data.user.type.isTeamAdmin import com.wire.kalium.logic.feature.client.IsWireCellsEnabledUseCase import com.wire.kalium.logic.feature.conversation.ConversationUpdateReceiptModeResult +import com.wire.kalium.logic.feature.conversation.MigrateConversationToMLSUseCase import com.wire.kalium.logic.feature.conversation.ObserveConversationDetailsUseCase import com.wire.kalium.logic.feature.conversation.UpdateConversationReceiptModeUseCase +import com.wire.kalium.logic.feature.debug.GetFeatureConfigResult +import com.wire.kalium.logic.feature.debug.GetFeatureConfigUseCase import com.wire.kalium.logic.feature.featureConfig.AppsAllowedResult import com.wire.kalium.logic.feature.featureConfig.ObserveIsAppsAllowedForUsageUseCase import com.wire.kalium.logic.feature.publicuser.RefreshUsersWithoutMetadataUseCase import com.wire.kalium.logic.feature.selfDeletingMessages.ObserveSelfDeletionTimerSettingsForConversationUseCase import com.wire.kalium.logic.feature.user.IsMLSEnabledUseCase import com.wire.kalium.logic.feature.user.ObserveSelfUserWithTeamUseCase +import com.wire.kalium.network.exceptions.KaliumException +import com.wire.kalium.util.DebugKaliumApi import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedFactory import dev.zacsweers.metro.AssistedInject @@ -66,6 +75,7 @@ import kotlinx.coroutines.withContext import com.wire.android.di.metro.WireAssistedViewModelBinding import com.wire.android.ui.home.conversations.ConversationDetailsManualViewModelFactoryGroup +@OptIn(DebugKaliumApi::class) @Suppress("TooManyFunctions", "LongParameterList") @WireAssistedViewModelBinding(ConversationDetailsManualViewModelFactoryGroup::class) class GroupConversationDetailsViewModel @AssistedInject constructor( @@ -80,6 +90,8 @@ class GroupConversationDetailsViewModel @AssistedInject constructor( private val isMLSEnabled: IsMLSEnabledUseCase, refreshUsersWithoutMetadata: RefreshUsersWithoutMetadataUseCase, private val isWireCellsEnabled: IsWireCellsEnabledUseCase, + private val getFeatureConfig: GetFeatureConfigUseCase, + private val migrateConversationToMLS: MigrateConversationToMLSUseCase, ) : ActionsViewModel(), GroupConversationParticipantsManager by GroupConversationParticipantsManagerImpl( conversationId = navigationArgs.conversationId, @@ -98,11 +110,33 @@ class GroupConversationDetailsViewModel @AssistedInject constructor( private val _isFetchingInitialData: MutableStateFlow = MutableStateFlow(true) val isFetchingInitialData: MutableStateFlow = _isFetchingInitialData + private val isMlsMigrationEnabled = MutableStateFlow(false) + private var protocolTapCount = 0 init { + loadMlsMigrationFeatureFlag() observeConversationDetails() } + private fun loadMlsMigrationFeatureFlag() { + viewModelScope.launch { + isMlsMigrationEnabled.value = when (val result = withContext(dispatcher.io()) { getFeatureConfig() }) { + is GetFeatureConfigResult.Success -> { + val migrationConfig = result.featureConfigModel.mlsMigrationModel + migrationConfig?.status == Status.ENABLED + } + + is GetFeatureConfigResult.Failure -> { + appLogger.w( + "[$TAG] Failed to load the manual MLS migration feature config " + + "(failureType=${result.coreFailure::class.simpleName})" + ) + false + } + } + } + } + private suspend fun groupDetailsFlow(): Flow = observeConversationDetails(conversationId) .filterIsInstance() .map { it.conversationDetails } @@ -131,7 +165,8 @@ class GroupConversationDetailsViewModel @AssistedInject constructor( selfWithTeamFlow, appsAllowedResultFlow, observeSelfDeletionTimerSettingsForConversation(conversationId, considerSelfUserSettings = false), - ) { groupDetails, (selfUser, selfTeam), appsAllowedResult, selfDeletionTimer -> + isMlsMigrationEnabled, + ) { groupDetails, (selfUser, selfTeam), appsAllowedResult, selfDeletionTimer, isMlsMigrationEnabled -> val selfType = selfUser.userType val isSelfInTeamThatOwnsConversation = selfTeam?.id != null && selfTeam.id == groupDetails.conversation.teamId?.value val isSelfExternalMember = selfUser.userType.isExternal() @@ -141,6 +176,10 @@ class GroupConversationDetailsViewModel @AssistedInject constructor( val canPerformChannelAdminTasks = isChannel && isSelfInTeamThatOwnsConversation && isSelfTeamAdmin val isRegularGroupAdmin = groupDetails.selfRole == Conversation.Member.Role.Admin val canSelfPerformAdminTasks = (isRegularGroupAdmin) || (canPerformChannelAdminTasks) + val isConversationInSelfTeam = groupDetails.conversation.teamId?.let { it == selfUser.teamId } == true + val canManuallyMigrateToMLS = isMlsMigrationEnabled && + groupDetails.conversation.protocol.isProteusOrMixed() && + isConversationInSelfTeam val channelPermissionType = groupDetails.getChannelPermissionType() val channelAccessType = groupDetails.getChannelAccessType() val isExternalOrFederated = @@ -170,6 +209,10 @@ class GroupConversationDetailsViewModel @AssistedInject constructor( val mlsEnabled = isMLSEnabled() val wireCellFeatureEnabled = isWireCellsEnabled() + if (!canManuallyMigrateToMLS) { + protocolTapCount = 0 + } + updateState( groupOptionsState.value.copy( groupName = groupDetails.conversation.name.orEmpty(), @@ -202,7 +245,10 @@ class GroupConversationDetailsViewModel @AssistedInject constructor( isWireCellEnabled = groupDetails.wireCell != null, isWireCellFeatureEnabled = wireCellFeatureEnabled, isSelfPartOfATeam = selfTeam != null, - canSelfAddParticipants = canSelfAddParticipants + canSelfAddParticipants = canSelfAddParticipants, + canManuallyMigrateToMLS = canManuallyMigrateToMLS, + shouldShowMlsMigrationDialog = groupOptionsState.value.shouldShowMlsMigrationDialog && + canManuallyMigrateToMLS, ) ) }.collect {} @@ -263,6 +309,74 @@ class GroupConversationDetailsViewModel @AssistedInject constructor( updateReadReceiptRemoteRequest(enableReadReceipt) } + fun onProtocolTapped() { + val state = groupOptionsState.value + if (!state.canManuallyMigrateToMLS) { + protocolTapCount = 0 + return + } + + protocolTapCount += 1 + if (protocolTapCount == MANUAL_MIGRATION_TAP_COUNT) { + protocolTapCount = 0 + appLogger.i("[$TAG] Manual MLS migration confirmation shown") + updateState(groupOptionsState.value.copy(shouldShowMlsMigrationDialog = true)) + } + } + + fun onMlsMigrationDialogDismissed() { + if (groupOptionsState.value.isMigratingToMLS) { + return + } + protocolTapCount = 0 + appLogger.i("[$TAG] Manual MLS migration confirmation dismissed") + updateState(groupOptionsState.value.copy(shouldShowMlsMigrationDialog = false)) + } + + fun onMlsMigrationConfirmed() { + if (!groupOptionsState.value.canManuallyMigrateToMLS || groupOptionsState.value.isMigratingToMLS) { + return + } + + appLogger.i( + "[$TAG] Manual MLS migration started " + + "(protocol=${groupOptionsState.value.protocolInfo.debugName()})" + ) + updateState(groupOptionsState.value.copy(isMigratingToMLS = true)) + viewModelScope.launch { + when (val result = migrateConversationToMLS(conversationId)) { + MigrateConversationToMLSUseCase.Result.Success -> { + appLogger.i("[$TAG] Manual MLS migration succeeded") + updateState( + groupOptionsState.value.copy( + shouldShowMlsMigrationDialog = false, + isMigratingToMLS = false, + ) + ) + sendAction(GroupConversationDetailsViewAction.Message(UIText.StringResource(R.string.mls_migration_success))) + } + + is MigrateConversationToMLSUseCase.Result.Failure -> { + val backendError = result.cause.backendErrorDetails() + val backendErrorLog = backendError?.let { + ", httpCode=${it.httpCode}, backendLabel=${it.label}" + }.orEmpty() + appLogger.e( + "[$TAG] Manual MLS migration failed " + + "(failureType=${result.cause::class.simpleName}$backendErrorLog)" + ) + updateState( + groupOptionsState.value.copy( + shouldShowMlsMigrationDialog = false, + isMigratingToMLS = false, + ) + ) + sendAction(GroupConversationDetailsViewAction.Message(result.cause.mlsMigrationFailureText())) + } + } + } + } + private fun updateReadReceiptRemoteRequest(enableReadReceipt: Boolean) { viewModelScope.launch { val result = withContext(dispatcher.io()) { @@ -296,9 +410,35 @@ class GroupConversationDetailsViewModel @AssistedInject constructor( companion object { const val TAG = "GroupConversationDetailsViewModel" + private const val MANUAL_MIGRATION_TAP_COUNT = 5 } } +private fun Conversation.ProtocolInfo.isProteusOrMixed(): Boolean = + this is Conversation.ProtocolInfo.Proteus || this is Conversation.ProtocolInfo.Mixed + +private fun Conversation.ProtocolInfo.debugName(): String = when (this) { + is Conversation.ProtocolInfo.MLS -> "MLS" + is Conversation.ProtocolInfo.Mixed -> "Mixed" + Conversation.ProtocolInfo.Proteus -> "Proteus" +} + +private fun CoreFailure.mlsMigrationFailureText(): UIText = backendErrorDetails()?.let { + UIText.StringResource(R.string.mls_migration_failure_with_backend_error, it.httpCode, it.label) +} ?: UIText.StringResource(R.string.mls_migration_failure) + +private fun CoreFailure.backendErrorDetails(): BackendErrorDetails? = + (this as? NetworkFailure.ServerMiscommunication)?.kaliumException?.let { exception -> + when (exception) { + is KaliumException.RedirectError -> exception.errorResponse + is KaliumException.InvalidRequestError -> exception.errorResponse + is KaliumException.ServerError -> exception.errorResponse + else -> null + } + }?.let { BackendErrorDetails(it.code, it.label) } + +private data class BackendErrorDetails(val httpCode: Int, val label: String) + sealed interface GroupConversationDetailsViewAction { data class Message(val text: UIText) : GroupConversationDetailsViewAction } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/options/GroupConversationOptions.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/options/GroupConversationOptions.kt index 45c6adea7b6..cbb1f9a3798 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/options/GroupConversationOptions.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/options/GroupConversationOptions.kt @@ -40,13 +40,16 @@ import androidx.compose.ui.unit.times import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.wire.android.BuildConfig import com.wire.android.R +import com.wire.android.model.ClickBlockParams import com.wire.android.model.Clickable import com.wire.android.ui.common.WireDialog import com.wire.android.ui.common.WireDialogButtonProperties import com.wire.android.ui.common.WireDialogButtonType +import com.wire.android.ui.common.button.WireButtonState import com.wire.android.ui.common.colorsScheme import com.wire.android.ui.common.dimensions import com.wire.android.ui.common.rowitem.SectionHeader +import com.wire.android.ui.common.wireDialogPropertiesBuilder import com.wire.android.ui.home.conversations.details.GroupConversationDetailsViewModel import com.wire.android.ui.home.conversations.selfdeletion.SelfDeletionMapper.toSelfDeletionDuration import com.wire.android.ui.home.newconversation.channelaccess.ChannelAccessType @@ -84,7 +87,16 @@ fun GroupConversationOptions( onReadReceiptSwitchClicked = viewModel::onReadReceiptUpdate, lazyListState = lazyListState, onEditGroupName = onEditGroupName, + onProtocolClicked = viewModel::onProtocolTapped, ) + + if (state.shouldShowMlsMigrationDialog) { + MlsMigrationConfirmationDialog( + isLoading = state.isMigratingToMLS, + onConfirm = viewModel::onMlsMigrationConfirmed, + onDismiss = viewModel::onMlsMigrationDialogDismissed, + ) + } } @Composable @@ -96,6 +108,7 @@ fun GroupConversationSettings( onSelfDeletingClicked: () -> Unit, onReadReceiptSwitchClicked: (Boolean) -> Unit, onEditGroupName: () -> Unit, + onProtocolClicked: () -> Unit, modifier: Modifier = Modifier, lazyListState: LazyListState = rememberLazyListState(), mlsReadReceiptsEnabled: Boolean = BuildConfig.MLS_READ_RECEIPTS_ENABLED, @@ -219,7 +232,10 @@ fun GroupConversationSettings( folderWithItems( folderTitleResId = R.string.folder_label_protocol_details, - items = conversationProtocolDetailsItems(protocolInfo = state.protocolInfo), + items = conversationProtocolDetailsItems( + protocolInfo = state.protocolInfo, + onProtocolClicked = onProtocolClicked, + ), ) } } @@ -258,11 +274,16 @@ private fun MutableList.addIf(condition: Boolean, element: E) { private fun conversationProtocolDetailsItems( protocolInfo: Conversation.ProtocolInfo, + onProtocolClicked: () -> Unit, ): List<@Composable () -> Unit> = buildList { add { ProtocolDetails( label = UIText.StringResource(R.string.protocol), - text = UIText.DynamicString(protocolInfo.name()) + text = UIText.DynamicString(protocolInfo.name()), + clickable = Clickable( + clickBlockParams = ClickBlockParams(debounceClicks = false), + onClick = onProtocolClicked, + ), ) } @@ -315,15 +336,45 @@ private fun GroupNameItem( } @Composable -private fun ProtocolDetails(label: UIText, text: UIText) { +private fun ProtocolDetails(label: UIText, text: UIText, clickable: Clickable = Clickable(enabled = false)) { GroupConversationOptionsItem( label = label.asString(), title = text.asString(), - arrowType = ArrowType.NONE + arrowType = ArrowType.NONE, + clickable = clickable, ) HorizontalDivider(thickness = Dp.Hairline, color = MaterialTheme.wireColorScheme.divider) } +@Composable +private fun MlsMigrationConfirmationDialog( + isLoading: Boolean, + onConfirm: () -> Unit, + onDismiss: () -> Unit, +) { + WireDialog( + title = stringResource(R.string.mls_migration_dialog_title), + text = stringResource(R.string.mls_migration_dialog_description), + onDismiss = onDismiss, + buttonsHorizontalAlignment = false, + properties = wireDialogPropertiesBuilder( + dismissOnBackPress = !isLoading, + dismissOnClickOutside = false, + ), + optionButton1Properties = WireDialogButtonProperties( + onClick = onConfirm, + text = stringResource(R.string.mls_migration_dialog_confirm), + type = WireDialogButtonType.Primary, + loading = isLoading, + ), + dismissButtonProperties = WireDialogButtonProperties( + onClick = onDismiss, + text = stringResource(R.string.label_cancel), + state = if (isLoading) WireButtonState.Disabled else WireButtonState.Default, + ), + ) +} + @Composable private fun ReadReceiptOption( isSwitchEnabled: Boolean, @@ -421,6 +472,7 @@ private fun PreviewGroupConversationOptions(state: GroupConversationOptionsState onAppsAccessItemClicked = {}, onReadReceiptSwitchClicked = {}, onEditGroupName = {}, + onProtocolClicked = {}, modifier = Modifier, lazyListState = rememberLazyListState(), mlsReadReceiptsEnabled = false diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/options/GroupConversationOptionsState.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/options/GroupConversationOptionsState.kt index e8ab6cc21a7..fdc251e3d0d 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/options/GroupConversationOptionsState.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/options/GroupConversationOptionsState.kt @@ -70,7 +70,10 @@ data class GroupConversationOptionsState( val isWireCellFeatureEnabled: Boolean = false, val isWireCellEnabled: Boolean = false, val isSelfPartOfATeam: Boolean = false, - val canSelfAddParticipants: Boolean = false + val canSelfAddParticipants: Boolean = false, + val canManuallyMigrateToMLS: Boolean = false, + val shouldShowMlsMigrationDialog: Boolean = false, + val isMigratingToMLS: Boolean = false, ) { sealed interface Error { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8d6d20e7bea..c6ec4838a98 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -569,6 +569,12 @@ Give this group a meaningful name. Give this channel a meaningful name. Protocol + Change protocol to MLS? + The standard messaging protocol will change from Proteus to Messaging Layer Security (MLS). + Change Protocol + Conversation migrated to MLS successfully. + Conversation migration to MLS failed. + Conversation migration to MLS failed (HTTP %1$d, label: %2$s). MLS Cipher Suite Last Key Material Update diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversations/details/GroupDetailsViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversations/details/GroupDetailsViewModelTest.kt index fa72864bff0..dfaf34a3831 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/conversations/details/GroupDetailsViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversations/details/GroupDetailsViewModelTest.kt @@ -19,6 +19,8 @@ package com.wire.android.ui.home.conversations.details +import app.cash.turbine.test +import com.wire.android.R import com.wire.android.config.CoroutineTestExtension import com.wire.android.config.TestDispatcherProvider import com.wire.android.framework.TestConversation @@ -30,12 +32,18 @@ import com.wire.android.ui.home.conversations.details.participants.model.Convers import com.wire.android.ui.home.conversations.details.participants.usecase.ObserveParticipantsForConversationUseCase import com.wire.android.ui.home.newconversation.channelaccess.ChannelAccessType import com.wire.android.ui.home.newconversation.channelaccess.ChannelAddPermissionType +import com.wire.android.util.ui.UIText +import com.wire.kalium.common.error.CoreFailure +import com.wire.kalium.common.error.NetworkFailure import com.wire.kalium.logic.data.conversation.Conversation import com.wire.kalium.logic.data.conversation.ConversationDetails import com.wire.kalium.logic.data.conversation.ConversationDetails.Group.Channel.ChannelAccess import com.wire.kalium.logic.data.conversation.ConversationDetails.Group.Channel.ChannelAddPermission import com.wire.kalium.logic.data.conversation.ConversationHistorySettings import com.wire.kalium.logic.data.conversation.MutedConversationStatus +import com.wire.kalium.logic.data.featureConfig.FeatureConfigModel +import com.wire.kalium.logic.data.featureConfig.MLSMigrationModel +import com.wire.kalium.logic.data.featureConfig.Status import com.wire.kalium.logic.data.id.ConversationId import com.wire.kalium.logic.data.id.TeamId import com.wire.kalium.logic.data.message.SelfDeletionTimer @@ -48,10 +56,13 @@ import com.wire.kalium.logic.feature.client.IsWireCellsEnabledUseCase import com.wire.kalium.logic.feature.conversation.ArchiveStatusUpdateResult import com.wire.kalium.logic.feature.conversation.ConversationUpdateReceiptModeResult import com.wire.kalium.logic.feature.conversation.ConversationUpdateStatusResult +import com.wire.kalium.logic.feature.conversation.MigrateConversationToMLSUseCase import com.wire.kalium.logic.feature.conversation.ObserveConversationDetailsUseCase import com.wire.kalium.logic.feature.conversation.UpdateConversationArchivedStatusUseCase import com.wire.kalium.logic.feature.conversation.UpdateConversationMutedStatusUseCase import com.wire.kalium.logic.feature.conversation.UpdateConversationReceiptModeUseCase +import com.wire.kalium.logic.feature.debug.GetFeatureConfigResult +import com.wire.kalium.logic.feature.debug.GetFeatureConfigUseCase import com.wire.kalium.logic.feature.featureConfig.AppsAllowedProtocol import com.wire.kalium.logic.feature.featureConfig.AppsAllowedResult import com.wire.kalium.logic.feature.featureConfig.ObserveIsAppsAllowedForUsageUseCase @@ -59,10 +70,14 @@ import com.wire.kalium.logic.feature.publicuser.RefreshUsersWithoutMetadataUseCa import com.wire.kalium.logic.feature.selfDeletingMessages.ObserveSelfDeletionTimerSettingsForConversationUseCase import com.wire.kalium.logic.feature.user.ObserveSelfUserWithTeamUseCase import com.wire.kalium.logic.feature.user.IsMLSEnabledUseCase +import com.wire.kalium.network.api.model.GenericAPIErrorResponse +import com.wire.kalium.network.exceptions.KaliumException import io.mockk.MockKAnnotations import io.mockk.coEvery import io.mockk.coVerify +import io.mockk.every import io.mockk.impl.annotations.MockK +import io.mockk.mockk import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.flowOf @@ -76,6 +91,195 @@ import org.junit.jupiter.api.extension.ExtendWith @OptIn(ExperimentalCoroutinesApi::class) @ExtendWith(CoroutineTestExtension::class) class GroupDetailsViewModelTest { + @Test + fun `given Proteus conversation admin and migration flag enabled, then fifth protocol tap shows migration dialog`() = runTest { + val details = testGroup.copy( + conversation = testGroup.conversation.copy(protocol = Conversation.ProtocolInfo.Proteus), + selfRole = Conversation.Member.Role.Admin, + ) + val (_, viewModel) = GroupConversationDetailsViewModelArrangement() + .withMlsMigrationFeatureFlag(Status.ENABLED, startTime = Instant.DISTANT_FUTURE) + .withSelfTeamId(TeamId("team_id")) + .withConversationDetailUpdate(details) + .arrange() + + assertEquals(true, viewModel.groupOptionsState.value.canManuallyMigrateToMLS) + repeat(4) { viewModel.onProtocolTapped() } + assertEquals(false, viewModel.groupOptionsState.value.shouldShowMlsMigrationDialog) + + viewModel.onProtocolTapped() + + assertEquals(true, viewModel.groupOptionsState.value.shouldShowMlsMigrationDialog) + } + + @Test + fun `given Mixed conversation admin and migration flag enabled, then manual migration is allowed`() = runTest { + val mixedProtocolInfo = with(TestConversation.MLS_PROTOCOL_INFO) { + Conversation.ProtocolInfo.Mixed(groupId, groupState, epoch, keyingMaterialLastUpdate, cipherSuite) + } + val details = testGroup.copy( + conversation = testGroup.conversation.copy(protocol = mixedProtocolInfo), + selfRole = Conversation.Member.Role.Admin, + ) + val (_, viewModel) = GroupConversationDetailsViewModelArrangement() + .withMlsMigrationFeatureFlag(Status.ENABLED) + .withSelfTeamId(TeamId("team_id")) + .withConversationDetailUpdate(details) + .arrange() + + assertEquals(true, viewModel.groupOptionsState.value.canManuallyMigrateToMLS) + } + + @Test + fun `given conversation member and migration flag enabled, then protocol taps show migration dialog`() = runTest { + val details = testGroup.copy( + conversation = testGroup.conversation.copy(protocol = Conversation.ProtocolInfo.Proteus), + selfRole = Conversation.Member.Role.Member, + ) + val (_, viewModel) = GroupConversationDetailsViewModelArrangement() + .withMlsMigrationFeatureFlag(Status.ENABLED) + .withSelfTeamId(TeamId("team_id")) + .withConversationDetailUpdate(details) + .arrange() + + repeat(5) { viewModel.onProtocolTapped() } + + assertEquals(true, viewModel.groupOptionsState.value.canManuallyMigrateToMLS) + assertEquals(true, viewModel.groupOptionsState.value.shouldShowMlsMigrationDialog) + } + + @Test + fun `given conversation belongs to another team, then protocol taps do not show migration dialog`() = runTest { + val details = testGroup.copy( + conversation = testGroup.conversation.copy(protocol = Conversation.ProtocolInfo.Proteus), + selfRole = Conversation.Member.Role.Admin, + ) + val (_, viewModel) = GroupConversationDetailsViewModelArrangement() + .withMlsMigrationFeatureFlag(Status.ENABLED) + .withSelfTeamId(TeamId("other_team_id")) + .withConversationDetailUpdate(details) + .arrange() + + repeat(5) { viewModel.onProtocolTapped() } + + assertEquals(false, viewModel.groupOptionsState.value.canManuallyMigrateToMLS) + assertEquals(false, viewModel.groupOptionsState.value.shouldShowMlsMigrationDialog) + } + + @Test + fun `given migration flag disabled, then protocol taps do not show migration dialog`() = runTest { + val details = testGroup.copy( + conversation = testGroup.conversation.copy(protocol = Conversation.ProtocolInfo.Proteus), + selfRole = Conversation.Member.Role.Admin, + ) + val (_, viewModel) = GroupConversationDetailsViewModelArrangement() + .withMlsMigrationFeatureFlag(Status.DISABLED) + .withSelfTeamId(TeamId("team_id")) + .withConversationDetailUpdate(details) + .arrange() + + repeat(5) { viewModel.onProtocolTapped() } + + assertEquals(false, viewModel.groupOptionsState.value.canManuallyMigrateToMLS) + assertEquals(false, viewModel.groupOptionsState.value.shouldShowMlsMigrationDialog) + } + + @Test + fun `given MLS conversation admin and migration flag enabled, then manual migration is not allowed`() = runTest { + val details = testGroup.copy( + conversation = testGroup.conversation.copy(protocol = TestConversation.MLS_PROTOCOL_INFO), + selfRole = Conversation.Member.Role.Admin, + ) + val (_, viewModel) = GroupConversationDetailsViewModelArrangement() + .withMlsMigrationFeatureFlag(Status.ENABLED) + .withSelfTeamId(TeamId("team_id")) + .withConversationDetailUpdate(details) + .arrange() + + assertEquals(false, viewModel.groupOptionsState.value.canManuallyMigrateToMLS) + } + + @Test + fun `given migration dialog is shown, when confirmed, then conversation migration is started`() = runTest { + val details = testGroup.copy( + conversation = testGroup.conversation.copy(protocol = Conversation.ProtocolInfo.Proteus), + selfRole = Conversation.Member.Role.Admin, + ) + val (arrangement, viewModel) = GroupConversationDetailsViewModelArrangement() + .withMlsMigrationFeatureFlag(Status.ENABLED) + .withSelfTeamId(TeamId("team_id")) + .withConversationDetailUpdate(details) + .arrange() + repeat(5) { viewModel.onProtocolTapped() } + + viewModel.onMlsMigrationConfirmed() + + coVerify(exactly = 1) { arrangement.migrateConversationToMLS(arrangement.conversationId) } + assertEquals(false, viewModel.groupOptionsState.value.shouldShowMlsMigrationDialog) + assertEquals(false, viewModel.groupOptionsState.value.isMigratingToMLS) + } + + @Test + fun `given backend network error, when migration fails, then toast includes http code and label`() = runTest { + val backendError = GenericAPIErrorResponse( + code = 409, + message = "Migration rejected", + label = "mls-migration-rejected", + ) + val migrationFailure = MigrateConversationToMLSUseCase.Result.Failure( + NetworkFailure.ServerMiscommunication(KaliumException.InvalidRequestError(backendError)) + ) + val details = testGroup.copy( + conversation = testGroup.conversation.copy(protocol = Conversation.ProtocolInfo.Proteus), + ) + val (_, viewModel) = GroupConversationDetailsViewModelArrangement() + .withMlsMigrationFeatureFlag(Status.ENABLED) + .withSelfTeamId(TeamId("team_id")) + .withMigrateConversationToMLSResult(migrationFailure) + .withConversationDetailUpdate(details) + .arrange() + + viewModel.actions.test { + repeat(5) { viewModel.onProtocolTapped() } + viewModel.onMlsMigrationConfirmed() + + assertEquals( + GroupConversationDetailsViewAction.Message( + UIText.StringResource( + R.string.mls_migration_failure_with_backend_error, + backendError.code, + backendError.label, + ) + ), + awaitItem(), + ) + } + } + + @Test + fun `given non backend error, when migration fails, then toast uses generic message`() = runTest { + val migrationFailure = MigrateConversationToMLSUseCase.Result.Failure(CoreFailure.Unknown(null)) + val details = testGroup.copy( + conversation = testGroup.conversation.copy(protocol = Conversation.ProtocolInfo.Proteus), + ) + val (_, viewModel) = GroupConversationDetailsViewModelArrangement() + .withMlsMigrationFeatureFlag(Status.ENABLED) + .withSelfTeamId(TeamId("team_id")) + .withMigrateConversationToMLSResult(migrationFailure) + .withConversationDetailUpdate(details) + .arrange() + + viewModel.actions.test { + repeat(5) { viewModel.onProtocolTapped() } + viewModel.onMlsMigrationConfirmed() + + assertEquals( + GroupConversationDetailsViewAction.Message(UIText.StringResource(R.string.mls_migration_failure)), + awaitItem(), + ) + } + } + @Test fun `given a group conversation, when solving the conversation name, then the name of the conversation is used`() = runTest { // Given @@ -774,6 +978,12 @@ internal class GroupConversationDetailsViewModelArrangement { @MockK lateinit var isWireCellsEnabled: IsWireCellsEnabledUseCase + @MockK + lateinit var getFeatureConfig: GetFeatureConfigUseCase + + @MockK + lateinit var migrateConversationToMLS: MigrateConversationToMLSUseCase + private var arrangedSelfUser: SelfUser = TestUser.SELF_USER private var arrangedTeam: Team? = TestTeam.TEAM @@ -790,6 +1000,8 @@ internal class GroupConversationDetailsViewModelArrangement { observeSelfDeletionTimerSettingsForConversation = observeSelfDeletionTimerSettingsForConversation, refreshUsersWithoutMetadata = refreshUsersWithoutMetadata, isWireCellsEnabled = isWireCellsEnabled, + getFeatureConfig = getFeatureConfig, + migrateConversationToMLS = migrateConversationToMLS, ) } @@ -808,6 +1020,8 @@ internal class GroupConversationDetailsViewModelArrangement { coEvery { observeSelfDeletionTimerSettingsForConversation(any(), any()) } returns flowOf(SelfDeletionTimer.Disabled) coEvery { updateConversationArchivedStatus(any(), any(), any()) } returns ArchiveStatusUpdateResult.Success coEvery { isWireCellsEnabled() } returns false + coEvery { migrateConversationToMLS(any()) } returns MigrateConversationToMLSUseCase.Result.Success + withMlsMigrationFeatureFlag(Status.DISABLED) withAppsAllowedResult(AppsAllowedResult.Disabled) } @@ -815,11 +1029,30 @@ internal class GroupConversationDetailsViewModelArrangement { coEvery { observeIsAppsAllowedForUsage() } returns flowOf(result) } + fun withMlsMigrationFeatureFlag(status: Status, startTime: Instant? = null) = apply { + val featureConfigModel = mockk() + every { featureConfigModel.mlsMigrationModel } returns MLSMigrationModel( + startTime = startTime, + endTime = null, + status = status, + ) + coEvery { getFeatureConfig() } returns GetFeatureConfigResult.Success(featureConfigModel) + } + + fun withMigrateConversationToMLSResult(result: MigrateConversationToMLSUseCase.Result) = apply { + coEvery { migrateConversationToMLS(any()) } returns result + } + suspend fun withGetSelfUserReturns(user: SelfUser) = apply { arrangedSelfUser = user updateSelfUserWithTeamFlow() } + suspend fun withSelfTeamId(teamId: TeamId?) = apply { + arrangedSelfUser = arrangedSelfUser.copy(teamId = teamId) + updateSelfUserWithTeamFlow() + } + suspend fun withConversationDetailUpdate(conversationDetails: ConversationDetails) = apply { coEvery { observeConversationDetails(any()) } returns conversationDetailsFlow .map { ObserveConversationDetailsUseCase.Result.Success(it) } diff --git a/core/ui-common/src/main/kotlin/com/wire/android/model/Clickable.kt b/core/ui-common/src/main/kotlin/com/wire/android/model/Clickable.kt index af5a24822f6..0dd16167b53 100644 --- a/core/ui-common/src/main/kotlin/com/wire/android/model/Clickable.kt +++ b/core/ui-common/src/main/kotlin/com/wire/android/model/Clickable.kt @@ -30,4 +30,5 @@ data class Clickable( data class ClickBlockParams( val blockWhenSyncing: Boolean = false, val blockWhenConnecting: Boolean = false, + val debounceClicks: Boolean = true, ) diff --git a/core/ui-common/src/main/kotlin/com/wire/android/ui/common/Extensions.kt b/core/ui-common/src/main/kotlin/com/wire/android/ui/common/Extensions.kt index b6ddf1bcb31..198743787f0 100644 --- a/core/ui-common/src/main/kotlin/com/wire/android/ui/common/Extensions.kt +++ b/core/ui-common/src/main/kotlin/com/wire/android/ui/common/Extensions.kt @@ -102,7 +102,11 @@ fun rememberClickBlockAction(clickBlockParams: ClickBlockParams, clickAction: () clickBlockParams.blockWhenSyncing && syncStateObserver.isSyncing -> Toast.makeText(context, waitUntilSynchronised, Toast.LENGTH_SHORT).show() - else -> clickerHandler.ensureSingleClick { clickAction() } + else -> if (clickBlockParams.debounceClicks) { + clickerHandler.ensureSingleClick { clickAction() } + } else { + clickAction() + } } } }