Skip to content

Commit b7dc878

Browse files
committed
Ensure we wait for user confirmation of session verified before going to next step.
1 parent 80810cd commit b7dc878

File tree

5 files changed

+118
-89
lines changed

5 files changed

+118
-89
lines changed

features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/FtueFlowNode.kt

Lines changed: 12 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@ import androidx.compose.runtime.Composable
1414
import androidx.compose.ui.Alignment
1515
import androidx.compose.ui.Modifier
1616
import androidx.lifecycle.lifecycleScope
17-
import com.bumble.appyx.core.lifecycle.subscribe
1817
import com.bumble.appyx.core.modality.BuildContext
1918
import com.bumble.appyx.core.node.Node
2019
import com.bumble.appyx.core.plugin.Plugin
@@ -30,18 +29,16 @@ import io.element.android.features.ftue.impl.notifications.NotificationsOptInNod
3029
import io.element.android.features.ftue.impl.sessionverification.FtueSessionVerificationFlowNode
3130
import io.element.android.features.ftue.impl.state.DefaultFtueService
3231
import io.element.android.features.ftue.impl.state.FtueStep
32+
import io.element.android.features.ftue.impl.state.InternalFtueState
3333
import io.element.android.features.lockscreen.api.LockScreenEntryPoint
3434
import io.element.android.libraries.architecture.BackstackView
3535
import io.element.android.libraries.architecture.BaseFlowNode
3636
import io.element.android.libraries.architecture.createNode
3737
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
3838
import io.element.android.libraries.di.SessionScope
39-
import io.element.android.services.analytics.api.AnalyticsService
40-
import kotlinx.coroutines.flow.distinctUntilChanged
41-
import kotlinx.coroutines.flow.filter
39+
import kotlinx.coroutines.flow.filterIsInstance
4240
import kotlinx.coroutines.flow.launchIn
4341
import kotlinx.coroutines.flow.onEach
44-
import kotlinx.coroutines.launch
4542
import kotlinx.parcelize.Parcelize
4643

4744
@ContributesNode(SessionScope::class)
@@ -51,7 +48,6 @@ class FtueFlowNode(
5148
@Assisted plugins: List<Plugin>,
5249
private val defaultFtueService: DefaultFtueService,
5350
private val analyticsEntryPoint: AnalyticsEntryPoint,
54-
private val analyticsService: AnalyticsService,
5551
private val lockScreenEntryPoint: LockScreenEntryPoint,
5652
) : BaseFlowNode<FtueFlowNode.NavTarget>(
5753
backstack = BackStack(
@@ -80,19 +76,11 @@ class FtueFlowNode(
8076

8177
override fun onBuilt() {
8278
super.onBuilt()
83-
84-
lifecycle.subscribe(onCreate = {
85-
moveToNextStepIfNeeded()
86-
})
87-
88-
analyticsService.didAskUserConsentFlow
89-
.distinctUntilChanged()
90-
.onEach { moveToNextStepIfNeeded() }
91-
.launchIn(lifecycleScope)
92-
93-
defaultFtueService.isVerificationStatusKnown
94-
.filter { it }
95-
.onEach { moveToNextStepIfNeeded() }
79+
defaultFtueService.ftueStepStateFlow
80+
.filterIsInstance(InternalFtueState.Incomplete::class)
81+
.onEach {
82+
showStep(it.nextStep)
83+
}
9684
.launchIn(lifecycleScope)
9785
}
9886

@@ -104,15 +92,15 @@ class FtueFlowNode(
10492
is NavTarget.SessionVerification -> {
10593
val callback = object : FtueSessionVerificationFlowNode.Callback {
10694
override fun onDone() {
107-
moveToNextStepIfNeeded()
95+
defaultFtueService.onUserCompletedSessionVerification()
10896
}
10997
}
11098
createNode<FtueSessionVerificationFlowNode>(buildContext, listOf(callback))
11199
}
112100
NavTarget.NotificationsOptIn -> {
113101
val callback = object : NotificationsOptInNode.Callback {
114102
override fun onNotificationsOptInFinished() {
115-
moveToNextStepIfNeeded()
103+
defaultFtueService.updateFtueStep()
116104
}
117105
}
118106
createNode<NotificationsOptInNode>(buildContext, listOf(callback))
@@ -123,7 +111,7 @@ class FtueFlowNode(
123111
NavTarget.LockScreenSetup -> {
124112
val callback = object : LockScreenEntryPoint.Callback {
125113
override fun onSetupDone() {
126-
moveToNextStepIfNeeded()
114+
defaultFtueService.updateFtueStep()
127115
}
128116
}
129117
lockScreenEntryPoint.nodeBuilder(this, buildContext, LockScreenEntryPoint.Target.Setup)
@@ -133,8 +121,8 @@ class FtueFlowNode(
133121
}
134122
}
135123

136-
private fun moveToNextStepIfNeeded() = lifecycleScope.launch {
137-
when (defaultFtueService.getNextStep()) {
124+
private fun showStep(ftueStep: FtueStep) {
125+
when (ftueStep) {
138126
FtueStep.WaitingForInitialState -> {
139127
backstack.newRoot(NavTarget.Placeholder)
140128
}
@@ -150,7 +138,6 @@ class FtueFlowNode(
150138
FtueStep.LockscreenSetup -> {
151139
backstack.newRoot(NavTarget.LockScreenSetup)
152140
}
153-
null -> Unit
154141
}
155142
}
156143

features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/state/DefaultFtueService.kt

Lines changed: 40 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,13 @@ package io.element.android.features.ftue.impl.state
99

1010
import android.Manifest
1111
import android.os.Build
12-
import androidx.annotation.VisibleForTesting
1312
import dev.zacsweers.metro.ContributesBinding
1413
import dev.zacsweers.metro.Inject
1514
import dev.zacsweers.metro.SingleIn
1615
import io.element.android.features.ftue.api.state.FtueService
1716
import io.element.android.features.ftue.api.state.FtueState
1817
import io.element.android.features.lockscreen.api.LockScreenService
18+
import io.element.android.libraries.core.coroutine.mapState
1919
import io.element.android.libraries.di.SessionScope
2020
import io.element.android.libraries.di.annotations.SessionCoroutineScope
2121
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
@@ -26,26 +26,39 @@ import io.element.android.services.analytics.api.AnalyticsService
2626
import io.element.android.services.toolbox.api.sdk.BuildVersionSdkIntProvider
2727
import kotlinx.coroutines.CoroutineScope
2828
import kotlinx.coroutines.flow.MutableStateFlow
29+
import kotlinx.coroutines.flow.combine
2930
import kotlinx.coroutines.flow.distinctUntilChanged
3031
import kotlinx.coroutines.flow.filter
3132
import kotlinx.coroutines.flow.first
3233
import kotlinx.coroutines.flow.launchIn
3334
import kotlinx.coroutines.flow.map
3435
import kotlinx.coroutines.flow.onEach
36+
import kotlinx.coroutines.launch
3537

3638
@ContributesBinding(SessionScope::class)
3739
@SingleIn(SessionScope::class)
3840
@Inject
3941
class DefaultFtueService(
4042
private val sdkVersionProvider: BuildVersionSdkIntProvider,
41-
@SessionCoroutineScope sessionCoroutineScope: CoroutineScope,
43+
@SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope,
4244
private val analyticsService: AnalyticsService,
4345
private val permissionStateProvider: PermissionStateProvider,
4446
private val lockScreenService: LockScreenService,
4547
private val sessionVerificationService: SessionVerificationService,
4648
private val sessionPreferencesStore: SessionPreferencesStore,
4749
) : FtueService {
48-
override val state = MutableStateFlow<FtueState>(FtueState.Unknown)
50+
private val userNeedsToConfirmSessionVerificationSuccess = MutableStateFlow(false)
51+
52+
val ftueStepStateFlow = MutableStateFlow<InternalFtueState>(InternalFtueState.Unknown)
53+
54+
override val state = ftueStepStateFlow
55+
.mapState {
56+
when (it) {
57+
is InternalFtueState.Unknown -> FtueState.Unknown
58+
is InternalFtueState.Incomplete -> FtueState.Incomplete
59+
is InternalFtueState.Complete -> FtueState.Complete
60+
}
61+
}
4962

5063
/**
5164
* This flow emits true when the FTUE flow is ready to be displayed.
@@ -63,24 +76,37 @@ class DefaultFtueService(
6376
}
6477

6578
init {
66-
sessionVerificationService.sessionVerifiedStatus
67-
.onEach { updateState() }
79+
combine(
80+
sessionVerificationService.sessionVerifiedStatus.onEach { it ->
81+
if (it == SessionVerifiedStatus.NotVerified) {
82+
// Ensure we wait for the user to confirm the session verified screen before going further
83+
userNeedsToConfirmSessionVerificationSuccess.value = true
84+
}
85+
},
86+
userNeedsToConfirmSessionVerificationSuccess,
87+
analyticsService.didAskUserConsentFlow.distinctUntilChanged(),
88+
) {
89+
updateFtueStep()
90+
}
6891
.launchIn(sessionCoroutineScope)
92+
}
6993

70-
analyticsService.didAskUserConsentFlow
71-
.distinctUntilChanged()
72-
.onEach { updateState() }
73-
.launchIn(sessionCoroutineScope)
94+
fun updateFtueStep() = sessionCoroutineScope.launch {
95+
val step = getNextStep(null)
96+
ftueStepStateFlow.value = when (step) {
97+
null -> InternalFtueState.Complete
98+
else -> InternalFtueState.Incomplete(step)
99+
}
74100
}
75101

76-
suspend fun getNextStep(currentStep: FtueStep? = null): FtueStep? =
77-
when (currentStep) {
102+
private suspend fun getNextStep(completedStep: FtueStep? = null): FtueStep? =
103+
when (completedStep) {
78104
null -> if (!isSessionVerificationStateReady()) {
79105
FtueStep.WaitingForInitialState
80106
} else {
81107
getNextStep(FtueStep.WaitingForInitialState)
82108
}
83-
FtueStep.WaitingForInitialState -> if (isSessionNotVerified()) {
109+
FtueStep.WaitingForInitialState -> if (isSessionNotVerified() || userNeedsToConfirmSessionVerificationSuccess.value) {
84110
FtueStep.SessionVerification
85111
} else {
86112
getNextStep(FtueStep.SessionVerification)
@@ -137,14 +163,8 @@ class DefaultFtueService(
137163
return lockScreenService.isSetupRequired().first()
138164
}
139165

140-
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
141-
internal suspend fun updateState() {
142-
val nextStep = getNextStep()
143-
state.value = when {
144-
// Final state, there aren't any more next steps
145-
nextStep == null -> FtueState.Complete
146-
else -> FtueState.Incomplete
147-
}
166+
fun onUserCompletedSessionVerification() {
167+
userNeedsToConfirmSessionVerificationSuccess.value = false
148168
}
149169
}
150170

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
/*
2+
* Copyright 2025 New Vector Ltd.
3+
*
4+
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
5+
* Please see LICENSE files in the repository root for full details.
6+
*/
7+
8+
package io.element.android.features.ftue.impl.state
9+
10+
sealed interface InternalFtueState {
11+
data object Unknown : InternalFtueState
12+
13+
data class Incomplete(
14+
val nextStep: FtueStep,
15+
) : InternalFtueState
16+
17+
data object Complete : InternalFtueState
18+
}

features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/DefaultFtueEntryPointTest.kt

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@ import com.bumble.appyx.core.modality.BuildContext
1414
import com.bumble.appyx.testing.junit4.util.MainDispatcherRule
1515
import com.google.common.truth.Truth.assertThat
1616
import io.element.android.features.lockscreen.api.LockScreenEntryPoint
17-
import io.element.android.services.analytics.test.FakeAnalyticsService
1817
import io.element.android.tests.testutils.lambda.lambdaError
1918
import io.element.android.tests.testutils.node.TestParentNode
2019
import kotlinx.coroutines.test.runTest
@@ -37,7 +36,6 @@ class DefaultFtueEntryPointTest {
3736
plugins = plugins,
3837
analyticsEntryPoint = { _, _ -> lambdaError() },
3938
defaultFtueService = createDefaultFtueService(),
40-
analyticsService = FakeAnalyticsService(),
4139
lockScreenEntryPoint = object : LockScreenEntryPoint {
4240
override fun nodeBuilder(
4341
parentNode: com.bumble.appyx.core.node.Node,

features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/DefaultFtueServiceTest.kt

Lines changed: 48 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import com.google.common.truth.Truth.assertThat
1313
import io.element.android.features.ftue.api.state.FtueState
1414
import io.element.android.features.ftue.impl.state.DefaultFtueService
1515
import io.element.android.features.ftue.impl.state.FtueStep
16+
import io.element.android.features.ftue.impl.state.InternalFtueState
1617
import io.element.android.features.lockscreen.api.LockScreenService
1718
import io.element.android.features.lockscreen.test.FakeLockScreenService
1819
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
@@ -69,9 +70,11 @@ class DefaultFtueServiceTest {
6970
analyticsService.setDidAskUserConsent()
7071
permissionStateProvider.setPermissionGranted()
7172
lockScreenService.setIsPinSetup(true)
72-
service.updateState()
73-
74-
assertThat(service.state.value).isEqualTo(FtueState.Complete)
73+
service.updateFtueStep()
74+
service.state.test {
75+
assertThat(awaitItem()).isEqualTo(FtueState.Unknown)
76+
assertThat(awaitItem()).isEqualTo(FtueState.Complete)
77+
}
7578
}
7679

7780
@Test
@@ -90,9 +93,11 @@ class DefaultFtueServiceTest {
9093
sessionVerificationService.emitVerifiedStatus(SessionVerifiedStatus.Verified)
9194
permissionStateProvider.setPermissionGranted()
9295
lockScreenService.setIsPinSetup(true)
93-
service.updateState()
94-
95-
assertThat(service.state.value).isEqualTo(FtueState.Complete)
96+
service.updateFtueStep()
97+
service.state.test {
98+
assertThat(awaitItem()).isEqualTo(FtueState.Unknown)
99+
assertThat(awaitItem()).isEqualTo(FtueState.Complete)
100+
}
96101
}
97102

98103
@Test
@@ -109,35 +114,30 @@ class DefaultFtueServiceTest {
109114
permissionStateProvider = permissionStateProvider,
110115
lockScreenService = lockScreenService,
111116
)
112-
val steps = mutableListOf<FtueStep?>()
113-
114-
// Session verification
115-
steps.add(service.getNextStep(steps.lastOrNull()))
116-
sessionVerificationService.emitVerifiedStatus(SessionVerifiedStatus.NotVerified)
117-
118-
// Notifications opt in
119-
steps.add(service.getNextStep(steps.lastOrNull()))
120-
permissionStateProvider.setPermissionGranted()
121-
122-
// Entering PIN code
123-
steps.add(service.getNextStep(steps.lastOrNull()))
124-
lockScreenService.setIsPinSetup(true)
125-
126-
// Analytics opt in
127-
steps.add(service.getNextStep(steps.lastOrNull()))
128-
analyticsService.setDidAskUserConsent()
129117

130-
// Final step (null)
131-
steps.add(service.getNextStep(steps.lastOrNull()))
132-
133-
assertThat(steps).containsExactly(
134-
FtueStep.SessionVerification,
135-
FtueStep.NotificationsOptIn,
136-
FtueStep.LockscreenSetup,
137-
FtueStep.AnalyticsOptIn,
138-
// Final state
139-
null,
140-
)
118+
service.ftueStepStateFlow.test {
119+
assertThat(awaitItem()).isEqualTo(InternalFtueState.Unknown)
120+
// Session verification
121+
assertThat(awaitItem()).isEqualTo(InternalFtueState.Incomplete(FtueStep.SessionVerification))
122+
sessionVerificationService.emitVerifiedStatus(SessionVerifiedStatus.Verified)
123+
// User completes verification
124+
service.onUserCompletedSessionVerification()
125+
// Notifications opt in
126+
assertThat(awaitItem()).isEqualTo(InternalFtueState.Incomplete(FtueStep.NotificationsOptIn))
127+
permissionStateProvider.setPermissionGranted()
128+
// Simulate event from NotificationsOptInNode.Callback.onNotificationsOptInFinished
129+
service.updateFtueStep()
130+
// Entering PIN code
131+
assertThat(awaitItem()).isEqualTo(InternalFtueState.Incomplete(FtueStep.LockscreenSetup))
132+
lockScreenService.setIsPinSetup(true)
133+
// Simulate event from LockScreenEntryPoint.Callback.onSetupDone()
134+
service.updateFtueStep()
135+
// Analytics opt in
136+
assertThat(awaitItem()).isEqualTo(InternalFtueState.Incomplete(FtueStep.AnalyticsOptIn))
137+
analyticsService.setDidAskUserConsent()
138+
// Final step
139+
assertThat(awaitItem()).isEqualTo(InternalFtueState.Complete)
140+
}
141141
}
142142

143143
@Test
@@ -158,10 +158,13 @@ class DefaultFtueServiceTest {
158158
permissionStateProvider.setPermissionGranted()
159159
lockScreenService.setIsPinSetup(true)
160160

161-
assertThat(service.getNextStep()).isEqualTo(FtueStep.AnalyticsOptIn)
162-
163-
analyticsService.setDidAskUserConsent()
164-
assertThat(service.getNextStep(null)).isNull()
161+
service.ftueStepStateFlow.test {
162+
assertThat(awaitItem()).isEqualTo(InternalFtueState.Unknown)
163+
// Analytics opt in
164+
assertThat(awaitItem()).isEqualTo(InternalFtueState.Incomplete(FtueStep.AnalyticsOptIn))
165+
analyticsService.setDidAskUserConsent()
166+
assertThat(awaitItem()).isEqualTo(InternalFtueState.Complete)
167+
}
165168
}
166169

167170
@Test
@@ -180,10 +183,13 @@ class DefaultFtueServiceTest {
180183
sessionVerificationService.emitVerifiedStatus(SessionVerifiedStatus.Verified)
181184
lockScreenService.setIsPinSetup(true)
182185

183-
assertThat(service.getNextStep()).isEqualTo(FtueStep.AnalyticsOptIn)
184-
185-
analyticsService.setDidAskUserConsent()
186-
assertThat(service.getNextStep(null)).isNull()
186+
service.ftueStepStateFlow.test {
187+
assertThat(awaitItem()).isEqualTo(InternalFtueState.Unknown)
188+
// Analytics opt in
189+
assertThat(awaitItem()).isEqualTo(InternalFtueState.Incomplete(FtueStep.AnalyticsOptIn))
190+
analyticsService.setDidAskUserConsent()
191+
assertThat(awaitItem()).isEqualTo(InternalFtueState.Complete)
192+
}
187193
}
188194

189195
@Test

0 commit comments

Comments
 (0)