Merge pull request #3359 from element-hq/feature/bma/signOutOnIdentityConfirmation

Add a way to sign out when the user is asked to verify the session.
This commit is contained in:
Benoit Marty 2024-08-30 16:42:23 +02:00 committed by GitHub
commit 26b2f5ddf4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 145 additions and 23 deletions

View file

@ -43,6 +43,7 @@ dependencies {
implementation(projects.libraries.designsystem)
implementation(projects.libraries.preferences.api)
implementation(projects.libraries.uiStrings)
implementation(projects.features.logout.api)
api(libs.statemachine)
api(projects.features.verifysession.api)
@ -52,6 +53,7 @@ dependencies {
testImplementation(libs.test.robolectric)
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(projects.features.logout.test)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.preferences.test)
testImplementation(projects.tests.testutils)

View file

@ -16,8 +16,10 @@
package io.element.android.features.verifysession.impl
import android.app.Activity
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
@ -25,6 +27,8 @@ import com.bumble.appyx.core.plugin.plugins
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.logout.api.util.onSuccessLogout
import io.element.android.features.verifysession.api.VerifySessionEntryPoint
import io.element.android.libraries.di.SessionScope
@ -39,12 +43,15 @@ class VerifySelfSessionNode @AssistedInject constructor(
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
val activity = LocalContext.current as Activity
val isDark = ElementTheme.isLightTheme.not()
VerifySelfSessionView(
state = state,
modifier = modifier,
onEnterRecoveryKey = callback::onEnterRecoveryKey,
onResetKey = callback::onResetKey,
onFinish = callback::onDone,
onSuccessLogout = { onSuccessLogout(activity, isDark, it) },
)
}
}

View file

@ -20,14 +20,19 @@ package io.element.android.features.verifysession.impl
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import com.freeletics.flowredux.compose.rememberStateAndDispatch
import io.element.android.features.logout.api.LogoutUseCase
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runCatchingUpdatingState
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.matrix.api.encryption.EncryptionService
import io.element.android.libraries.matrix.api.encryption.RecoveryState
@ -49,6 +54,7 @@ class VerifySelfSessionPresenter @Inject constructor(
private val stateMachine: VerifySelfSessionStateMachine,
private val buildMeta: BuildMeta,
private val sessionPreferencesStore: SessionPreferencesStore,
private val logoutUseCase: LogoutUseCase,
) : Presenter<VerifySelfSessionState> {
@Composable
override fun present(): VerifySelfSessionState {
@ -61,6 +67,9 @@ class VerifySelfSessionPresenter @Inject constructor(
val stateAndDispatch = stateMachine.rememberStateAndDispatch()
val skipVerification by sessionPreferencesStore.isSessionVerificationSkipped().collectAsState(initial = false)
val needsVerification by sessionVerificationService.needsSessionVerification.collectAsState(initial = true)
val signOutAction = remember {
mutableStateOf<AsyncAction<String?>>(AsyncAction.Uninitialized)
}
val verificationFlowStep by remember {
derivedStateOf {
when {
@ -85,6 +94,7 @@ class VerifySelfSessionPresenter @Inject constructor(
VerifySelfSessionViewEvents.DeclineVerification -> stateAndDispatch.dispatchAction(StateMachineEvent.DeclineChallenge)
VerifySelfSessionViewEvents.Cancel -> stateAndDispatch.dispatchAction(StateMachineEvent.Cancel)
VerifySelfSessionViewEvents.Reset -> stateAndDispatch.dispatchAction(StateMachineEvent.Reset)
VerifySelfSessionViewEvents.SignOut -> coroutineScope.signOut(signOutAction)
VerifySelfSessionViewEvents.SkipVerification -> coroutineScope.launch {
sessionPreferencesStore.setSkipSessionVerification(true)
}
@ -92,6 +102,7 @@ class VerifySelfSessionPresenter @Inject constructor(
}
return VerifySelfSessionState(
verificationFlowStep = verificationFlowStep,
signOutAction = signOutAction.value,
displaySkipButton = buildMeta.isDebuggable,
eventSink = ::handleEvents,
)
@ -160,4 +171,10 @@ class VerifySelfSessionPresenter @Inject constructor(
}
}.launchIn(this)
}
private fun CoroutineScope.signOut(signOutAction: MutableState<AsyncAction<String?>>) = launch {
suspend {
logoutUseCase.logout(ignoreSdkError = true)
}.runCatchingUpdatingState(signOutAction)
}
}

View file

@ -18,12 +18,14 @@ package io.element.android.features.verifysession.impl
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.verification.SessionVerificationData
@Immutable
data class VerifySelfSessionState(
val verificationFlowStep: VerificationStep,
val signOutAction: AsyncAction<String?>,
val displaySkipButton: Boolean,
val eventSink: (VerifySelfSessionViewEvents) -> Unit,
) {

View file

@ -18,6 +18,7 @@ package io.element.android.features.verifysession.impl
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.verifysession.impl.VerifySelfSessionState.VerificationStep
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.verification.SessionVerificationData
import io.element.android.libraries.matrix.api.verification.VerificationEmoji
@ -54,6 +55,10 @@ open class VerifySelfSessionStateProvider : PreviewParameterProvider<VerifySelfS
verificationFlowStep = VerificationStep.Completed,
displaySkipButton = true,
),
aVerifySelfSessionState(
signOutAction = AsyncAction.Loading,
displaySkipButton = true,
),
// Add other state here
)
}
@ -72,12 +77,14 @@ private fun aDecimalsSessionVerificationData(
internal fun aVerifySelfSessionState(
verificationFlowStep: VerificationStep = VerificationStep.Initial(canEnterRecoveryKey = false),
signOutAction: AsyncAction<String?> = AsyncAction.Uninitialized,
displaySkipButton: Boolean = false,
eventSink: (VerifySelfSessionViewEvents) -> Unit = {},
) = VerifySelfSessionState(
verificationFlowStep = verificationFlowStep,
displaySkipButton = displaySkipButton,
eventSink = eventSink,
signOutAction = signOutAction,
)
private fun aVerificationEmojiList() = listOf(

View file

@ -37,6 +37,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
@ -46,11 +47,13 @@ import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.verifysession.impl.emoji.toEmojiResource
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule
import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage
import io.element.android.libraries.designsystem.components.BigIcon
import io.element.android.libraries.designsystem.components.PageTitle
import io.element.android.libraries.designsystem.components.ProgressDialog
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Button
@ -70,11 +73,13 @@ fun VerifySelfSessionView(
onEnterRecoveryKey: () -> Unit,
onResetKey: () -> Unit,
onFinish: () -> Unit,
onSuccessLogout: (String?) -> Unit,
modifier: Modifier = Modifier,
) {
fun resetFlow() {
state.eventSink(VerifySelfSessionViewEvents.Reset)
}
val latestOnFinish by rememberUpdatedState(newValue = onFinish)
LaunchedEffect(state.verificationFlowStep, latestOnFinish) {
if (state.verificationFlowStep is FlowStep.Skipped) {
@ -97,17 +102,25 @@ fun VerifySelfSessionView(
HeaderFooterPage(
modifier = modifier,
topBar = {
TopAppBar(
title = {},
actions = {
if (state.displaySkipButton && state.verificationFlowStep != FlowStep.Completed) {
TextButton(
text = stringResource(CommonStrings.action_skip),
onClick = { state.eventSink(VerifySelfSessionViewEvents.SkipVerification) }
)
}
}
)
TopAppBar(
title = {},
actions = {
if (state.verificationFlowStep != FlowStep.Completed &&
state.displaySkipButton &&
LocalInspectionMode.current.not()) {
TextButton(
text = stringResource(CommonStrings.action_skip),
onClick = { state.eventSink(VerifySelfSessionViewEvents.SkipVerification) }
)
}
if (state.verificationFlowStep is FlowStep.Initial) {
TextButton(
text = stringResource(CommonStrings.action_signout),
onClick = { state.eventSink(VerifySelfSessionViewEvents.SignOut) }
)
}
}
)
},
header = {
HeaderContent(verificationFlowStep = verificationFlowStep)
@ -124,6 +137,21 @@ fun VerifySelfSessionView(
) {
Content(flowState = verificationFlowStep)
}
when (state.signOutAction) {
AsyncAction.Loading -> {
ProgressDialog(text = stringResource(id = R.string.screen_signout_in_progress_dialog_content))
}
is AsyncAction.Success -> {
val latestOnSuccessLogout by rememberUpdatedState(onSuccessLogout)
LaunchedEffect(state) {
latestOnSuccessLogout(state.signOutAction.data)
}
}
AsyncAction.Confirming,
is AsyncAction.Failure,
AsyncAction.Uninitialized -> Unit
}
}
@Composable
@ -367,5 +395,6 @@ internal fun VerifySelfSessionViewPreview(@PreviewParameter(VerifySelfSessionSta
onEnterRecoveryKey = {},
onResetKey = {},
onFinish = {},
onSuccessLogout = {},
)
}

View file

@ -23,5 +23,6 @@ sealed interface VerifySelfSessionViewEvents {
data object DeclineVerification : VerifySelfSessionViewEvents
data object Cancel : VerifySelfSessionViewEvents
data object Reset : VerifySelfSessionViewEvents
data object SignOut : VerifySelfSessionViewEvents
data object SkipVerification : VerifySelfSessionViewEvents
}

View file

@ -28,4 +28,5 @@
<string name="screen_session_verification_they_match">"They match"</string>
<string name="screen_session_verification_waiting_to_accept_subtitle">"Accept the request to start the verification process in your other session to continue."</string>
<string name="screen_session_verification_waiting_to_accept_title">"Waiting to accept request"</string>
<string name="screen_signout_in_progress_dialog_content">"Signing out…"</string>
</resources>

View file

@ -21,6 +21,8 @@ import app.cash.molecule.moleculeFlow
import app.cash.turbine.ReceiveTurbine
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.features.logout.api.LogoutUseCase
import io.element.android.features.logout.test.FakeLogoutUseCase
import io.element.android.features.verifysession.impl.VerifySelfSessionState.VerificationStep
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.core.meta.BuildMeta
@ -36,6 +38,8 @@ import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService
import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService
import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Rule
@ -309,6 +313,31 @@ class VerifySelfSessionPresenterTest {
}
}
@Test
fun `present - When user request to sign out, the sign out use case is invoked`() = runTest {
val service = FakeSessionVerificationService().apply {
givenNeedsSessionVerification(false)
givenVerifiedStatus(SessionVerifiedStatus.Verified)
givenVerificationFlowState(VerificationFlowState.Finished)
}
val signOutLambda = lambdaRecorder<Boolean, String?> { "aUrl" }
val presenter = createVerifySelfSessionPresenter(
service,
logoutUseCase = FakeLogoutUseCase(signOutLambda)
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(1)
val initialItem = awaitItem()
initialItem.eventSink(VerifySelfSessionViewEvents.SignOut)
val finalItem = awaitItem()
assertThat(finalItem.signOutAction.isSuccess()).isTrue()
assertThat(finalItem.signOutAction.dataOrNull()).isEqualTo("aUrl")
signOutLambda.assertions().isCalledOnce().with(value(true))
}
}
private suspend fun ReceiveTurbine<VerifySelfSessionState>.requestVerificationAndAwaitVerifyingState(
fakeService: FakeSessionVerificationService,
sessionVerificationData: SessionVerificationData = SessionVerificationData.Emojis(emptyList()),
@ -344,6 +373,7 @@ class VerifySelfSessionPresenterTest {
encryptionService: EncryptionService = FakeEncryptionService(),
buildMeta: BuildMeta = aBuildMeta(),
sessionPreferencesStore: InMemorySessionPreferencesStore = InMemorySessionPreferencesStore(),
logoutUseCase: LogoutUseCase = FakeLogoutUseCase(),
): VerifySelfSessionPresenter {
return VerifySelfSessionPresenter(
sessionVerificationService = service,
@ -351,6 +381,7 @@ class VerifySelfSessionPresenterTest {
stateMachine = VerifySelfSessionStateMachine(service, encryptionService),
buildMeta = buildMeta,
sessionPreferencesStore = sessionPreferencesStore,
logoutUseCase = logoutUseCase,
)
}
}

View file

@ -20,6 +20,7 @@ import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureNeverCalled
@ -27,6 +28,7 @@ import io.element.android.tests.testutils.EnsureNeverCalledWithParam
import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.ensureCalledOnce
import io.element.android.tests.testutils.ensureCalledOnceWithParam
import io.element.android.tests.testutils.pressBackKey
import org.junit.Rule
import org.junit.Test
@ -213,11 +215,26 @@ class VerifySelfSessionViewTest {
}
}
@Test
fun `on success logout - onFinished callback is called immediately`() {
val aUrl = "aUrl"
ensureCalledOnceWithParam<String?>(aUrl) { callback ->
rule.setVerifySelfSessionView(
aVerifySelfSessionState(
signOutAction = AsyncAction.Success(aUrl),
eventSink = EnsureNeverCalledWithParam(),
),
onSuccessLogout = callback,
)
}
}
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setVerifySelfSessionView(
state: VerifySelfSessionState,
onEnterRecoveryKey: () -> Unit = EnsureNeverCalled(),
onFinished: () -> Unit = EnsureNeverCalled(),
onResetKey: () -> Unit = EnsureNeverCalled(),
onSuccessLogout: (String?) -> Unit = EnsureNeverCalledWithParam(),
) {
setContent {
VerifySelfSessionView(
@ -225,6 +242,7 @@ class VerifySelfSessionViewTest {
onEnterRecoveryKey = onEnterRecoveryKey,
onFinish = onFinished,
onResetKey = onResetKey,
onSuccessLogout = onSuccessLogout,
)
}
}

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:711074ce017e2909ae1cb610925bf7be96e182095cb08de5ca1d57b4dd206c92
size 30834
oid sha256:205fc4655fed7b11ef133e0df13b8175676ff38d398fde45a2e6a3b140a216e2
size 31440

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:1f7797415de4a52095a47dc7beb5b51c7528a90e9c8daf5e4d3eff030b22f3f7
size 32596

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c242503ced2d16da2fd8c738e198689b60d00d40d423b52820aa01a622acd39a
size 29695
oid sha256:205fc4655fed7b11ef133e0df13b8175676ff38d398fde45a2e6a3b140a216e2
size 31440

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d8d6fedd34ea54f9492bdc74f784aac902717328e3eda83f97d06e1eef970424
size 24201
oid sha256:2a8350fe244b42863ee1fb60c403aa2c695a5152755f73bf27598e71e033ef0b
size 25962

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:df4be3e3577698e939dc02ebc0819768c04525a6b6cc67386e33501610dff5b2
size 29959
oid sha256:4307ad367eb24d8f7590dfadf4e4b728ec4845ba4720b72824f5b2c60175f8fd
size 30555

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:a66446c1dc2ff1e32fd06ee722aaaed578369e6de0a0024d435104bcadc47d49
size 31012

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:a229523ad1f17f957adaffb47e8a64679bc3636ab94641bc492f98b7f5d3fe3d
size 28861
oid sha256:4307ad367eb24d8f7590dfadf4e4b728ec4845ba4720b72824f5b2c60175f8fd
size 30555

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:b090b019f8d0a98b9e0ec4e4bd53628af15737eb441f36f40ba4abceb4c75608
size 23622
oid sha256:b0da42a76838a54311a5ea6ec54725f3bb9c91c4ff7bc09ff571d6cd7b6796e1
size 25369

View file

@ -55,6 +55,7 @@
"name" : ":features:verifysession:impl",
"includeRegex" : [
"screen_session_verification_.*",
"screen_signout_in_progress_dialog_content",
"screen_identity_.*"
]
},