From b62d10024f4b47ad070aa9f59e40221be5ff57ba Mon Sep 17 00:00:00 2001 From: Oussama Hassine Date: Fri, 26 Jan 2024 17:08:19 +0100 Subject: [PATCH] feat: Show a dialog when current client's certificate is revoked (WPB-6145) - cherrypick (#2635) --- .../com/wire/android/ui/WireActivity.kt | 25 ++++++++++ .../wire/android/ui/WireActivityViewModel.kt | 22 ++++++++ .../android/ui/common/WireLabelledCheckbox.kt | 8 ++- .../com/wire/android/ui/home/E2EIDialogs.kt | 50 +++++++++++++++---- .../wire/android/ui/home/FeatureFlagState.kt | 1 + .../sync/FeatureFlagNotificationViewModel.kt | 16 ++++++ .../self/dialog/LogoutOptionsDialog.kt | 12 +++-- app/src/main/res/values/strings.xml | 4 ++ .../FeatureFlagNotificationViewModelTest.kt | 20 ++++++++ 9 files changed, 142 insertions(+), 16 deletions(-) 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 b3bf8103ea..2c9fd2c909 100644 --- a/app/src/main/kotlin/com/wire/android/ui/WireActivity.kt +++ b/app/src/main/kotlin/com/wire/android/ui/WireActivity.kt @@ -55,6 +55,7 @@ import com.wire.android.BuildConfig import com.wire.android.appLogger import com.wire.android.config.CustomUiConfigurationProvider import com.wire.android.config.LocalCustomUiConfigurationProvider +import com.wire.android.datastore.UserDataStore import com.wire.android.feature.NavigationSwitchAccountActions import com.wire.android.navigation.BackStackMode import com.wire.android.navigation.NavigationCommand @@ -65,6 +66,7 @@ import com.wire.android.ui.calling.ProximitySensorManager import com.wire.android.ui.common.snackbar.LocalSnackbarHostState import com.wire.android.ui.common.topappbar.CommonTopAppBar import com.wire.android.ui.common.topappbar.CommonTopAppBarViewModel +import com.wire.android.ui.common.visbility.rememberVisibilityState import com.wire.android.ui.destinations.ConversationScreenDestination import com.wire.android.ui.destinations.E2EIEnrollmentScreenDestination import com.wire.android.ui.destinations.E2eiCertificateDetailsScreenDestination @@ -78,6 +80,7 @@ import com.wire.android.ui.destinations.OtherUserProfileScreenDestination import com.wire.android.ui.destinations.SelfDevicesScreenDestination import com.wire.android.ui.destinations.SelfUserProfileScreenDestination import com.wire.android.ui.destinations.WelcomeScreenDestination +import com.wire.android.ui.home.E2EICertificateRevokedDialog import com.wire.android.ui.home.E2EIRequiredDialog import com.wire.android.ui.home.E2EIResultDialog import com.wire.android.ui.home.E2EISnoozeDialog @@ -91,6 +94,8 @@ import com.wire.android.ui.legalhold.dialog.requested.LegalHoldRequestedState import com.wire.android.ui.legalhold.dialog.requested.LegalHoldRequestedViewModel import com.wire.android.ui.theme.ThemeOption import com.wire.android.ui.theme.WireTheme +import com.wire.android.ui.userprofile.self.dialog.LogoutOptionsDialog +import com.wire.android.ui.userprofile.self.dialog.LogoutOptionsDialogState import com.wire.android.util.CurrentScreenManager import com.wire.android.util.LocalSyncStateObserver import com.wire.android.util.SyncStateObserver @@ -340,6 +345,26 @@ class WireActivity : AppCompatActivity() { hideDialogStatus = featureFlagNotificationViewModel::dismissSelfDeletingMessagesDialog ) } + val logoutOptionsDialogState = rememberVisibilityState() + + LogoutOptionsDialog( + dialogState = logoutOptionsDialogState, + checkboxEnabled = false, + logout = { + viewModel.doHardLogout( + { UserDataStore(context, it) }, + NavigationSwitchAccountActions(navigate) + ) + logoutOptionsDialogState.dismiss() + } + ) + + if (shouldShowE2eiCertificateRevokedDialog) { + E2EICertificateRevokedDialog( + onLogout = { logoutOptionsDialogState.show(LogoutOptionsDialogState(shouldWipeData = true)) }, + onContinue = featureFlagNotificationViewModel::dismissE2EICertificateRevokedDialog, + ) + } e2EIRequired?.let { E2EIRequiredDialog( 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 84574392d9..8d21d615c6 100644 --- a/app/src/main/kotlin/com/wire/android/ui/WireActivityViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/WireActivityViewModel.kt @@ -35,6 +35,7 @@ import com.wire.android.di.ObserveSyncStateUseCaseProvider import com.wire.android.feature.AccountSwitchUseCase import com.wire.android.feature.SwitchAccountActions import com.wire.android.feature.SwitchAccountParam +import com.wire.android.feature.SwitchAccountResult import com.wire.android.migration.MigrationManager import com.wire.android.services.ServicesManager import com.wire.android.ui.authentication.devices.model.displayName @@ -310,6 +311,27 @@ class WireActivityViewModel @Inject constructor( } } + // TODO: needs to be covered with test once hard logout is validated to be used + fun doHardLogout( + clearUserData: (userId: UserId) -> Unit, + switchAccountActions: SwitchAccountActions + ) { + viewModelScope.launch { + coreLogic.getGlobalScope().session.currentSession().takeIf { + it is CurrentSessionResult.Success + }?.let { + val currentUserId = (it as CurrentSessionResult.Success).accountInfo.userId + coreLogic.getSessionScope(currentUserId).logout(LogoutReason.SELF_HARD_LOGOUT) + clearUserData(currentUserId) + } + accountSwitch(SwitchAccountParam.TryToSwitchToNextAccount).also { + if (it == SwitchAccountResult.NoOtherAccountToSwitch) { + globalDataStore.clearAppLockPasscode() + } + }.callAction(switchAccountActions) + } + } + fun dismissNewClientsDialog(userId: UserId) { globalAppState = globalAppState.copy(newClientDialog = null) viewModelScope.launch { diff --git a/app/src/main/kotlin/com/wire/android/ui/common/WireLabelledCheckbox.kt b/app/src/main/kotlin/com/wire/android/ui/common/WireLabelledCheckbox.kt index f5f740cfc8..390d779fcd 100644 --- a/app/src/main/kotlin/com/wire/android/ui/common/WireLabelledCheckbox.kt +++ b/app/src/main/kotlin/com/wire/android/ui/common/WireLabelledCheckbox.kt @@ -46,6 +46,7 @@ fun WireLabelledCheckbox( overflow: TextOverflow = TextOverflow.Visible, horizontalArrangement: Arrangement.Horizontal = Arrangement.Start, contentPadding: PaddingValues = PaddingValues(dimensions().spacing0x), + checkboxEnabled: Boolean = true, modifier: Modifier = Modifier ) { Row( @@ -55,12 +56,17 @@ fun WireLabelledCheckbox( .toggleable( value = checked, role = Role.Checkbox, - onValueChange = { onCheckClicked(!checked) } + onValueChange = { + if (checkboxEnabled) { + onCheckClicked(!checked) + } + } ) .padding(contentPadding) ) { Checkbox( checked = checked, + enabled = checkboxEnabled, onCheckedChange = null // null since we are handling the click on parent ) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/E2EIDialogs.kt b/app/src/main/kotlin/com/wire/android/ui/home/E2EIDialogs.kt index 993805ad25..98cc6c4acf 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/E2EIDialogs.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/E2EIDialogs.kt @@ -363,9 +363,39 @@ private fun E2EIRenewNoSnoozeDialog(isLoading: Boolean, updateCertificate: () -> ) } +@Composable +fun E2EICertificateRevokedDialog( + onLogout: () -> Unit, + onContinue: () -> Unit +) { + WireDialog( + title = stringResource(id = R.string.end_to_end_identity_certificate_revoked_dialog_title), + text = stringResource(id = R.string.end_to_end_identity_certificate_revoked_dialog_description), + onDismiss = onContinue, + optionButton1Properties = WireDialogButtonProperties( + onClick = onLogout, + text = stringResource(id = R.string.end_to_end_identity_certificate_revoked_dialog_button_logout), + type = WireDialogButtonType.Primary + ), + optionButton2Properties = WireDialogButtonProperties( + onClick = onContinue, + text = stringResource(id = R.string.end_to_end_identity_certificate_revoked_dialog_button_continue), + type = WireDialogButtonType.Secondary, + ), + buttonsHorizontalAlignment = false, + properties = DialogProperties(usePlatformDefaultWidth = false) + ) +} + +@PreviewMultipleThemes +@Composable +fun PreviewE2EICertificateRevokedDialog() { + E2EICertificateRevokedDialog({}, {}) +} + @PreviewMultipleThemes @Composable -fun previewE2EIdRequiredWithSnoozeDialog() { +fun PreviewE2EIdRequiredWithSnoozeDialog() { WireTheme { E2EIRequiredWithSnoozeDialog(false, {}) {} } @@ -373,7 +403,7 @@ fun previewE2EIdRequiredWithSnoozeDialog() { @PreviewMultipleThemes @Composable -fun previewE2EIdRequiredNoSnoozeDialog() { +fun PreviewE2EIdRequiredNoSnoozeDialog() { WireTheme { E2EIRequiredNoSnoozeDialog(false) {} } @@ -381,7 +411,7 @@ fun previewE2EIdRequiredNoSnoozeDialog() { @PreviewMultipleThemes @Composable -fun previewE2EIdRenewRequiredWithSnoozeDialog() { +fun PreviewE2EIdRenewRequiredWithSnoozeDialog() { WireTheme { E2EIRenewWithSnoozeDialog(false, {}) {} } @@ -389,7 +419,7 @@ fun previewE2EIdRenewRequiredWithSnoozeDialog() { @PreviewMultipleThemes @Composable -fun previewE2EIdRenewRequiredNoSnoozeDialog() { +fun PreviewE2EIdRenewRequiredNoSnoozeDialog() { WireTheme { E2EIRenewNoSnoozeDialog(false) {} } @@ -397,7 +427,7 @@ fun previewE2EIdRenewRequiredNoSnoozeDialog() { @PreviewMultipleThemes @Composable -fun previewE2EIdSnoozeDialog() { +fun PreviewE2EIdSnoozeDialog() { WireTheme { E2EISnoozeDialog(2.seconds) {} } @@ -405,7 +435,7 @@ fun previewE2EIdSnoozeDialog() { @PreviewMultipleThemes @Composable -fun previewE2EIRenewErrorDialogNoGracePeriod() { +fun PreviewE2EIRenewErrorDialogNoGracePeriod() { WireTheme { E2EIRenewErrorDialog(FeatureFlagState.E2EIRequired.NoGracePeriod.Renew, false, { }) {} } @@ -413,7 +443,7 @@ fun previewE2EIRenewErrorDialogNoGracePeriod() { @PreviewMultipleThemes @Composable -fun previewE2EIRenewErrorDialogWithGracePeriod() { +fun PreviewE2EIRenewErrorDialogWithGracePeriod() { WireTheme { E2EIRenewErrorDialog(FeatureFlagState.E2EIRequired.WithGracePeriod.Renew(2.days), false, { }) {} } @@ -421,7 +451,7 @@ fun previewE2EIRenewErrorDialogWithGracePeriod() { @PreviewMultipleThemes @Composable -fun previewE2EISuccessDialog() { +fun PreviewE2EISuccessDialog() { WireTheme { E2EISuccessDialog({ }) {} } @@ -429,7 +459,7 @@ fun previewE2EISuccessDialog() { @PreviewMultipleThemes @Composable -fun previewE2EIRenewErrorNoSnoozeDialog() { +fun PreviewE2EIRenewErrorNoSnoozeDialog() { WireTheme { E2EIErrorNoSnoozeDialog(false) { } } @@ -437,7 +467,7 @@ fun previewE2EIRenewErrorNoSnoozeDialog() { @PreviewMultipleThemes @Composable -fun previewE2EIRenewErrorWithSnoozeDialog() { +fun PreviewE2EIRenewErrorWithSnoozeDialog() { WireTheme { E2EIErrorWithSnoozeDialog(isE2EILoading = false, updateCertificate = {}) { } } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/FeatureFlagState.kt b/app/src/main/kotlin/com/wire/android/ui/home/FeatureFlagState.kt index 6282528519..3392113856 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/FeatureFlagState.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/FeatureFlagState.kt @@ -26,6 +26,7 @@ data class FeatureFlagState( val isFileSharingEnabledState: Boolean = true, val fileSharingRestrictedState: SharingRestrictedState? = null, val shouldShowGuestRoomLinkDialog: Boolean = false, + val shouldShowE2eiCertificateRevokedDialog: Boolean = false, val shouldShowTeamAppLockDialog: Boolean = false, val isTeamAppLockEnabled: Boolean = false, val isGuestRoomLinkEnabled: Boolean = true, diff --git a/app/src/main/kotlin/com/wire/android/ui/home/sync/FeatureFlagNotificationViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/sync/FeatureFlagNotificationViewModel.kt index 70007e52c2..45026da72d 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/sync/FeatureFlagNotificationViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/sync/FeatureFlagNotificationViewModel.kt @@ -120,6 +120,13 @@ class FeatureFlagNotificationViewModel @Inject constructor( launch { setE2EIRequiredState(userId) } launch { setTeamAppLockFeatureFlag(userId) } launch { observeCallEndedBecauseOfConversationDegraded(userId) } + launch { observeShouldNotifyForRevokedCertificate(userId) } + } + } + + private suspend fun observeShouldNotifyForRevokedCertificate(userId: UserId) { + coreLogic.getSessionScope(userId).observeShouldNotifyForRevokedCertificate().collect { + featureFlagState = featureFlagState.copy(shouldShowE2eiCertificateRevokedDialog = it) } } @@ -232,6 +239,15 @@ class FeatureFlagNotificationViewModel @Inject constructor( } } + fun dismissE2EICertificateRevokedDialog() { + featureFlagState = featureFlagState.copy(shouldShowE2eiCertificateRevokedDialog = false) + currentUserId?.let { + viewModelScope.launch { + coreLogic.getSessionScope(it).markNotifyForRevokedCertificateAsNotified() + } + } + } + fun dismissFileSharingDialog() { featureFlagState = featureFlagState.copy(showFileSharingDialog = false) viewModelScope.launch { diff --git a/app/src/main/kotlin/com/wire/android/ui/userprofile/self/dialog/LogoutOptionsDialog.kt b/app/src/main/kotlin/com/wire/android/ui/userprofile/self/dialog/LogoutOptionsDialog.kt index f1f9611887..221b012bcb 100644 --- a/app/src/main/kotlin/com/wire/android/ui/userprofile/self/dialog/LogoutOptionsDialog.kt +++ b/app/src/main/kotlin/com/wire/android/ui/userprofile/self/dialog/LogoutOptionsDialog.kt @@ -41,7 +41,8 @@ import com.wire.android.ui.common.visbility.VisibilityState @Composable fun LogoutOptionsDialog( dialogState: VisibilityState, - logout: (Boolean) -> Unit + logout: (Boolean) -> Unit, + checkboxEnabled: Boolean = true ) { VisibilityState(dialogState) { state -> WireDialog( @@ -61,15 +62,16 @@ fun LogoutOptionsDialog( ) ) { WireLabelledCheckbox( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = dimensions().spacing16x) + .clip(RoundedCornerShape(size = dimensions().spacing4x)), label = stringResource(R.string.dialog_logout_wipe_data_checkbox), checked = state.shouldWipeData, onCheckClicked = remember { { dialogState.show(state.copy(shouldWipeData = it)) } }, horizontalArrangement = Arrangement.Center, contentPadding = PaddingValues(vertical = dimensions().spacing4x), - modifier = Modifier - .fillMaxWidth() - .padding(bottom = dimensions().spacing16x) - .clip(RoundedCornerShape(size = dimensions().spacing4x)) + checkboxEnabled = checkboxEnabled ) } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1ef36c5223..497e9d0b39 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1274,6 +1274,10 @@ The certificate is updated and your device is verified. Certificate Details Certificate Details + End-to-end certificate revoked + Log out to reduce security risks. Then log in again, get a new certificate, and reset your password.\n\nIf you keep using this device, your conversations are no longer verified. + Log out + Continue Using This Device Start Recording Recording Audio… diff --git a/app/src/test/kotlin/com/wire/android/ui/home/sync/FeatureFlagNotificationViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/sync/FeatureFlagNotificationViewModelTest.kt index fa8363628a..6c93b0a7b3 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/sync/FeatureFlagNotificationViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/sync/FeatureFlagNotificationViewModelTest.kt @@ -37,6 +37,7 @@ import com.wire.kalium.logic.feature.session.CurrentSessionResult import com.wire.kalium.logic.feature.user.E2EIRequiredResult import com.wire.kalium.logic.feature.user.MarkEnablingE2EIAsNotifiedUseCase import com.wire.kalium.logic.feature.user.MarkSelfDeletionStatusAsNotifiedUseCase +import com.wire.kalium.logic.feature.user.e2ei.MarkNotifyForRevokedCertificateAsNotifiedUseCase import com.wire.kalium.logic.feature.user.guestroomlink.MarkGuestLinkFeatureFlagAsNotChangedUseCase import io.mockk.MockKAnnotations import io.mockk.coEvery @@ -284,6 +285,19 @@ class FeatureFlagNotificationViewModelTest { assertEquals(null, viewModel.featureFlagState.e2EIRequired) } + @Test + fun givenADisplayedDialog_whenDismissingIt_thenInvokeMarkFileSharingStatusAsNotifiedUseCaseOnce() = runTest { + val (arrangement, viewModel) = Arrangement() + .withCurrentSessionsFlow(flowOf(CurrentSessionResult.Success(AccountInfo.Valid(UserId("value", "domain"))))) + .arrange() + coEvery { arrangement.markNotifyForRevokedCertificateAsNotified() } returns Unit + + viewModel.dismissE2EICertificateRevokedDialog() + + assertEquals(false, viewModel.featureFlagState.shouldShowE2eiCertificateRevokedDialog) + coVerify(exactly = 1) { arrangement.markNotifyForRevokedCertificateAsNotified() } + } + private inner class Arrangement { @MockK @@ -310,6 +324,9 @@ class FeatureFlagNotificationViewModelTest { @MockK lateinit var globalDataStore: GlobalDataStore + @MockK + lateinit var markNotifyForRevokedCertificateAsNotified: MarkNotifyForRevokedCertificateAsNotifiedUseCase + val viewModel: FeatureFlagNotificationViewModel by lazy { FeatureFlagNotificationViewModel( coreLogic = coreLogic, @@ -332,6 +349,9 @@ class FeatureFlagNotificationViewModelTest { coEvery { coreLogic.getSessionScope(any()).observeGuestRoomLinkFeatureFlag.invoke() } returns flowOf() coEvery { coreLogic.getSessionScope(any()).observeE2EIRequired.invoke() } returns flowOf() coEvery { coreLogic.getSessionScope(any()).calls.observeEndCallDialog() } returns flowOf() + coEvery { coreLogic.getSessionScope(any()).observeShouldNotifyForRevokedCertificate() } returns flowOf() + every { coreLogic.getSessionScope(any()).markNotifyForRevokedCertificateAsNotified } returns + markNotifyForRevokedCertificateAsNotified coEvery { ppLockTeamFeatureConfigObserver() } returns flowOf(null) }