Skip to content

Commit

Permalink
Merge pull request #3359 from element-hq/feature/bma/signOutOnIdentit…
Browse files Browse the repository at this point in the history
…yConfirmation

Add a way to sign out when the user is asked to verify the session.
  • Loading branch information
bmarty authored Aug 30, 2024
2 parents f2ebd93 + cf31895 commit 26b2f5d
Show file tree
Hide file tree
Showing 19 changed files with 145 additions and 23 deletions.
2 changes: 2 additions & 0 deletions features/verifysession/impl/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,19 @@

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
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

Expand All @@ -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) },
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand All @@ -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 {
Expand All @@ -85,13 +94,15 @@ 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)
}
}
}
return VerifySelfSessionState(
verificationFlowStep = verificationFlowStep,
signOutAction = signOutAction.value,
displaySkipButton = buildMeta.isDebuggable,
eventSink = ::handleEvents,
)
Expand Down Expand Up @@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -54,6 +55,10 @@ open class VerifySelfSessionStateProvider : PreviewParameterProvider<VerifySelfS
verificationFlowStep = VerificationStep.Completed,
displaySkipButton = true,
),
aVerifySelfSessionState(
signOutAction = AsyncAction.Loading,
displaySkipButton = true,
),
// Add other state here
)
}
Expand All @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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) {
Expand All @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -367,5 +395,6 @@ internal fun VerifySelfSessionViewPreview(@PreviewParameter(VerifySelfSessionSta
onEnterRecoveryKey = {},
onResetKey = {},
onFinish = {},
onSuccessLogout = {},
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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()),
Expand Down Expand Up @@ -344,13 +373,15 @@ class VerifySelfSessionPresenterTest {
encryptionService: EncryptionService = FakeEncryptionService(),
buildMeta: BuildMeta = aBuildMeta(),
sessionPreferencesStore: InMemorySessionPreferencesStore = InMemorySessionPreferencesStore(),
logoutUseCase: LogoutUseCase = FakeLogoutUseCase(),
): VerifySelfSessionPresenter {
return VerifySelfSessionPresenter(
sessionVerificationService = service,
encryptionService = encryptionService,
stateMachine = VerifySelfSessionStateMachine(service, encryptionService),
buildMeta = buildMeta,
sessionPreferencesStore = sessionPreferencesStore,
logoutUseCase = logoutUseCase,
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,15 @@ 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
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
Expand Down Expand Up @@ -213,18 +215,34 @@ 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(
state = state,
onEnterRecoveryKey = onEnterRecoveryKey,
onFinish = onFinished,
onResetKey = onResetKey,
onSuccessLogout = onSuccessLogout,
)
}
}
Expand Down
Loading

0 comments on commit 26b2f5d

Please sign in to comment.