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 805c3c46450..2da990a7e31 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 @@ -21,6 +21,7 @@ package com.wire.android.ui.home import com.wire.android.ui.home.messagecomposer.state.SelfDeletionDuration +import kotlin.time.Duration data class FeatureFlagState( val showFileSharingDialog: Boolean = false, @@ -30,9 +31,18 @@ data class FeatureFlagState( val isGuestRoomLinkEnabled: Boolean = true, val shouldShowSelfDeletingMessagesDialog: Boolean = false, val enforcedTimeoutDuration: SelfDeletionDuration = SelfDeletionDuration.None, - val areSelfDeletedMessagesEnabled: Boolean = true + val areSelfDeletedMessagesEnabled: Boolean = true, + val e2EIRequired: E2EIRequired? = null, + val e2EISnoozeInfo: E2EISnooze? = null ) { enum class SharingRestrictedState { NONE, NO_USER, RESTRICTED_IN_TEAM } + + data class E2EISnooze(val timeLeft: Duration) + + sealed class E2EIRequired { + data class WithGracePeriod(val timeLeft: Duration) : E2EIRequired() + object NoGracePeriod : E2EIRequired() + } } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/HomeDialogs.kt b/app/src/main/kotlin/com/wire/android/ui/home/HomeDialogs.kt index 3421cfa8350..1b630908f39 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/HomeDialogs.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/HomeDialogs.kt @@ -18,12 +18,14 @@ * */ +@file:Suppress("TooManyFunctions") package com.wire.android.ui.home import android.content.Context import androidx.compose.runtime.Composable import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.compose.ui.window.DialogProperties import com.wire.android.R import com.wire.android.ui.common.WireDialog import com.wire.android.ui.common.WireDialogButtonProperties @@ -31,7 +33,9 @@ import com.wire.android.ui.common.WireDialogButtonType import com.wire.android.ui.home.messagecomposer.state.SelfDeletionDuration import com.wire.android.ui.theme.WireTheme import com.wire.android.util.CustomTabsHelper +import com.wire.android.util.toTimeLongLabelUiText import com.wire.android.util.ui.PreviewMultipleThemes +import kotlin.time.Duration.Companion.seconds @Composable fun FileRestrictionDialog( @@ -131,6 +135,113 @@ fun WelcomeNewUserDialog( ) } +@Composable +fun E2EIRequiredDialog( + result: FeatureFlagState.E2EIRequired, + getCertificate: () -> Unit, + snoozeDialog: (FeatureFlagState.E2EIRequired.WithGracePeriod) -> Unit, +) { + when (result) { + FeatureFlagState.E2EIRequired.NoGracePeriod -> E2EIdRequiredNoSnoozeDialog(getCertificate = getCertificate) + is FeatureFlagState.E2EIRequired.WithGracePeriod -> E2EIdRequiredWithSnoozeDialog( + result = result, + getCertificate = getCertificate, + snoozeDialog = snoozeDialog + ) + } +} + +@Composable +fun E2EIdRequiredWithSnoozeDialog( + result: FeatureFlagState.E2EIRequired.WithGracePeriod, + getCertificate: () -> Unit, + snoozeDialog: (FeatureFlagState.E2EIRequired.WithGracePeriod) -> Unit +) { + WireDialog( + title = stringResource(id = R.string.end_to_end_identity_required_dialog_title), + text = stringResource(id = R.string.end_to_end_identity_required_dialog_text), + onDismiss = { snoozeDialog(result) }, + optionButton1Properties = WireDialogButtonProperties( + onClick = getCertificate, + text = stringResource(id = R.string.end_to_end_identity_required_dialog_positive_button), + type = WireDialogButtonType.Primary, + ), + optionButton2Properties = WireDialogButtonProperties( + onClick = { snoozeDialog(result) }, + text = stringResource(id = R.string.end_to_end_identity_required_dialog_snooze_button), + type = WireDialogButtonType.Secondary, + ), + buttonsHorizontalAlignment = false, + properties = DialogProperties( + usePlatformDefaultWidth = false, + dismissOnBackPress = false, + dismissOnClickOutside = false + ) + ) +} + +@Composable +fun E2EIdRequiredNoSnoozeDialog(getCertificate: () -> Unit) { + WireDialog( + title = stringResource(id = R.string.end_to_end_identity_required_dialog_title), + text = stringResource(id = R.string.end_to_end_identity_required_dialog_text_no_snooze), + onDismiss = getCertificate, + optionButton1Properties = WireDialogButtonProperties( + onClick = getCertificate, + text = stringResource(id = R.string.end_to_end_identity_required_dialog_positive_button), + type = WireDialogButtonType.Primary, + ), + buttonsHorizontalAlignment = false, + properties = DialogProperties( + usePlatformDefaultWidth = false, + dismissOnBackPress = false, + dismissOnClickOutside = false + ) + ) +} + +@Composable +fun E2EIdSnoozeDialog( + state: FeatureFlagState.E2EISnooze, + dismissDialog: () -> Unit +) { + val timeText = state.timeLeft.toTimeLongLabelUiText().asString() + WireDialog( + title = stringResource(id = R.string.end_to_end_identity_required_dialog_title), + text = stringResource(id = R.string.end_to_end_identity_snooze_dialog_text, timeText), + onDismiss = dismissDialog, + optionButton1Properties = WireDialogButtonProperties( + onClick = dismissDialog, + text = stringResource(id = R.string.label_ok), + type = WireDialogButtonType.Primary, + ) + ) +} + +@PreviewMultipleThemes +@Composable +fun previewE2EIdRequiredWithSnoozeDialog() { + WireTheme { + E2EIdRequiredWithSnoozeDialog(FeatureFlagState.E2EIRequired.WithGracePeriod(2.seconds), {}) {} + } +} + +@PreviewMultipleThemes +@Composable +fun previewE2EIdRequiredNoSnoozeDialog() { + WireTheme { + E2EIdRequiredNoSnoozeDialog {} + } +} + +@PreviewMultipleThemes +@Composable +fun previewE2EIdSnoozeDialog() { + WireTheme { + E2EIdSnoozeDialog(FeatureFlagState.E2EISnooze(2.seconds)) {} + } +} + @PreviewMultipleThemes @Composable fun previewFileRestrictionDialog() { diff --git a/app/src/main/kotlin/com/wire/android/ui/home/HomeScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/HomeScreen.kt index 8fa147ec05d..479dd2f6921 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/HomeScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/HomeScreen.kt @@ -108,7 +108,7 @@ fun HomeScreen( with(featureFlagNotificationViewModel.featureFlagState) { if (showFileSharingDialog) { FileRestrictionDialog( - isFileSharingEnabled = featureFlagNotificationViewModel.featureFlagState.showFileSharingDialog, + isFileSharingEnabled = showFileSharingDialog, hideDialogStatus = featureFlagNotificationViewModel::dismissFileSharingDialog ) } @@ -127,6 +127,21 @@ fun HomeScreen( hideDialogStatus = featureFlagNotificationViewModel::dismissSelfDeletingMessagesDialog ) } + + e2EIRequired?.let { + E2EIRequiredDialog( + result = e2EIRequired, + getCertificate = featureFlagNotificationViewModel::getE2EICertificate, + snoozeDialog = featureFlagNotificationViewModel::snoozeE2EIdRequiredDialog + ) + } + + e2EISnoozeInfo?.let { + E2EIdSnoozeDialog( + state = e2EISnoozeInfo, + dismissDialog = featureFlagNotificationViewModel::dismissSnoozeE2EIdRequiredDialog + ) + } } HomeContent( 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 8e94ca0f551..f5a2721b1b4 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 @@ -37,6 +37,7 @@ import com.wire.kalium.logic.data.user.UserId import com.wire.kalium.logic.feature.selfDeletingMessages.TeamSelfDeleteTimer import com.wire.kalium.logic.feature.session.CurrentSessionResult import com.wire.kalium.logic.feature.session.CurrentSessionUseCase +import com.wire.kalium.logic.feature.user.E2EIRequiredResult import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.launch @@ -82,6 +83,7 @@ class FeatureFlagNotificationViewModel @Inject constructor( setFileSharingState(userId) observeTeamSettingsSelfDeletionStatus(userId) setGuestRoomLinkFeatureFlag(userId) + setE2EIRequiredState(userId) } } } @@ -147,6 +149,16 @@ class FeatureFlagNotificationViewModel @Inject constructor( } } + private fun setE2EIRequiredState(userId: UserId) = viewModelScope.launch { + coreLogic.getSessionScope(userId).observeE2EIRequired().collect { result -> + val state = when (result) { + is E2EIRequiredResult.WithGracePeriod -> FeatureFlagState.E2EIRequired.WithGracePeriod(result.gracePeriod) + E2EIRequiredResult.NoGracePeriod -> FeatureFlagState.E2EIRequired.NoGracePeriod + } + featureFlagState = featureFlagState.copy(e2EIRequired = state) + } + } + fun dismissSelfDeletingMessagesDialog() { featureFlagState = featureFlagState.copy(shouldShowSelfDeletingMessagesDialog = false) viewModelScope.launch { @@ -167,4 +179,25 @@ class FeatureFlagNotificationViewModel @Inject constructor( } featureFlagState = featureFlagState.copy(shouldShowGuestRoomLinkDialog = false) } + + fun getE2EICertificate() { + // TODO do the magic + featureFlagState = featureFlagState.copy(e2EIRequired = null) + } + + fun snoozeE2EIdRequiredDialog(result: FeatureFlagState.E2EIRequired.WithGracePeriod) { + featureFlagState = featureFlagState.copy( + e2EIRequired = null, + e2EISnoozeInfo = FeatureFlagState.E2EISnooze(result.timeLeft) + ) + currentUserId?.let { userId -> + viewModelScope.launch { + coreLogic.getSessionScope(userId).markE2EIRequiredAsNotified() + } + } + } + + fun dismissSnoozeE2EIdRequiredDialog() { + featureFlagState = featureFlagState.copy(e2EISnoozeInfo = null) + } } diff --git a/app/src/main/kotlin/com/wire/android/util/DurationUtil.kt b/app/src/main/kotlin/com/wire/android/util/DurationUtil.kt new file mode 100644 index 00000000000..011d2032ae1 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/util/DurationUtil.kt @@ -0,0 +1,36 @@ +/* + * Wire + * Copyright (C) 2023 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.util + +import com.wire.android.R +import com.wire.android.util.ui.UIText +import kotlin.time.Duration + +fun Duration.toTimeLongLabelUiText(): UIText.PluralResource = when { + inWholeDays >= DAYS_IN_WEEK -> { + val weeks = inWholeDays.toInt() / DAYS_IN_WEEK + UIText.PluralResource(R.plurals.weeks_long_label, weeks, weeks) + } + + inWholeDays >= 1 -> UIText.PluralResource(R.plurals.days_long_label, inWholeDays.toInt(), inWholeDays) + inWholeHours >= 1 -> UIText.PluralResource(R.plurals.hours_long_label, inWholeHours.toInt(), inWholeHours) + inWholeMinutes >= 1 -> UIText.PluralResource(R.plurals.minutes_long_label, inWholeMinutes.toInt(), inWholeMinutes) + else -> UIText.PluralResource(R.plurals.seconds_long_label, inWholeSeconds.toInt(), inWholeSeconds) +} + +private const val DAYS_IN_WEEK = 7 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0eb37fc1210..38ca1c3f3e9 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1091,4 +1091,11 @@ No Application has been found to do Action Delete Account If you continue, will send a message via email. Follow the link to permanetly delete your account. + + End-to-end identity certificate + As of today, your team uses end-to-end identity to make Wire’s usage more secure and practicable. The device verification takes place automatically using a certificate and replaces the previous manual process. This way, you communicate with the highest security standard.\n\nEnter the credentials of your identity provider in the next step to automatically get a verification certificate for this device. + Your team now uses end-to-end identity to make Wire’s usage more secure. The device verification takes place automatically using a certificate.\n\nEnter the credentials of your identity provider in the next step to automatically get a verification certificate for this device. + Get certificate + Remind me later + You can get the certificate in your Wire settings during the next %1$s. Open Devices and select Get Certificate for your current device.\nTo continue using Wire without interruption, retrieve it in time – it doesn’t take long. 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 ca7b08f2024..9368093f5fc 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 @@ -11,6 +11,8 @@ import com.wire.kalium.logic.data.user.UserId import com.wire.kalium.logic.feature.auth.AccountInfo import com.wire.kalium.logic.feature.session.CurrentSessionResult import com.wire.kalium.logic.feature.session.CurrentSessionUseCase +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.guestroomlink.MarkGuestLinkFeatureFlagAsNotChangedUseCase import io.mockk.MockKAnnotations @@ -32,6 +34,7 @@ import org.amshove.kluent.internal.assertEquals import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test +import kotlin.time.Duration.Companion.days @OptIn(ExperimentalCoroutinesApi::class) class FeatureFlagNotificationViewModelTest { @@ -124,6 +127,48 @@ class FeatureFlagNotificationViewModelTest { assertEquals(false, viewModel.featureFlagState.shouldShowSelfDeletingMessagesDialog) } + @Test + fun givenE2EIRequired_thenShowDialog() = runTest(mainThreadSurrogate) { + val (arrangement, viewModel) = Arrangement() + .withE2EIRequiredSettings(E2EIRequiredResult.NoGracePeriod) + .arrange() + viewModel.initialSync() + advanceUntilIdle() + + assertEquals(FeatureFlagState.E2EIRequired.NoGracePeriod, viewModel.featureFlagState.e2EIRequired) + } + + @Test + fun givenE2EIRequiredDialogShown_whenSnoozeCalled_thenItSnoozedAndDialogShown() = runTest(mainThreadSurrogate) { + val gracePeriod = 1.days + val (arrangement, viewModel) = Arrangement() + .withE2EIRequiredSettings(E2EIRequiredResult.WithGracePeriod(gracePeriod)) + .arrange() + viewModel.initialSync() + advanceUntilIdle() + + viewModel.snoozeE2EIdRequiredDialog(FeatureFlagState.E2EIRequired.WithGracePeriod(gracePeriod)) + advanceUntilIdle() + + assertEquals(null, viewModel.featureFlagState.e2EIRequired) + assertEquals(FeatureFlagState.E2EISnooze(gracePeriod), viewModel.featureFlagState.e2EISnoozeInfo) + coVerify(exactly = 1) { arrangement.markE2EIRequiredAsNotified() } + } + + @Test + fun givenSnoozeE2EIRequiredDialogShown_whenDismissCalled_thenItSnoozedAndDialogHidden() = runTest(mainThreadSurrogate) { + val gracePeriod = 1.days + val (arrangement, viewModel) = Arrangement() + .withE2EIRequiredSettings(E2EIRequiredResult.WithGracePeriod(gracePeriod)) + .arrange() + viewModel.snoozeE2EIdRequiredDialog(FeatureFlagState.E2EIRequired.WithGracePeriod(gracePeriod)) + advanceUntilIdle() + + viewModel.dismissSnoozeE2EIdRequiredDialog() + + assertEquals(null, viewModel.featureFlagState.e2EISnoozeInfo) + } + private inner class Arrangement { init { MockKAnnotations.init(this, relaxUnitFun = true) @@ -145,6 +190,9 @@ class FeatureFlagNotificationViewModelTest { @MockK lateinit var markSelfDeletingStatusAsNotified: MarkSelfDeletionStatusAsNotifiedUseCase + @MockK + lateinit var markE2EIRequiredAsNotified: MarkEnablingE2EIAsNotifiedUseCase + @MockK lateinit var navigationManager: NavigationManager @@ -156,8 +204,10 @@ class FeatureFlagNotificationViewModelTest { init { every { coreLogic.getSessionScope(any()).markGuestLinkFeatureFlagAsNotChanged } returns markGuestLinkFeatureFlagAsNotChanged every { coreLogic.getSessionScope(any()).markSelfDeletingMessagesAsNotified } returns markSelfDeletingStatusAsNotified + every { coreLogic.getSessionScope(any()).markE2EIRequiredAsNotified } returns markE2EIRequiredAsNotified coEvery { coreLogic.getSessionScope(any()).observeFileSharingStatus.invoke() } returns flowOf() coEvery { coreLogic.getSessionScope(any()).observeGuestRoomLinkFeatureFlag.invoke() } returns flowOf() + coEvery { coreLogic.getSessionScope(any()).observeE2EIRequired.invoke() } returns flowOf() } fun withCurrentSessions(result: CurrentSessionResult) = apply { @@ -176,6 +226,10 @@ class FeatureFlagNotificationViewModelTest { coEvery { coreLogic.getSessionScope(any()).observeGuestRoomLinkFeatureFlag() } returns stateFlow } + fun withE2EIRequiredSettings(result: E2EIRequiredResult) = apply { + coEvery { coreLogic.getSessionScope(any()).observeE2EIRequired() } returns flowOf(result) + } + fun arrange() = this to viewModel } } diff --git a/kalium b/kalium index 1d2b66fc447..c6605869425 160000 --- a/kalium +++ b/kalium @@ -1 +1 @@ -Subproject commit 1d2b66fc4476789574ffd7344ee5a7d635fd0758 +Subproject commit c66058694256e6782aefe67474e31f0628b9850e