Skip to content

Commit c646d6d

Browse files
authored
Merge pull request #5379 from element-hq/feature/bma/cleanupFtueCode
Cleanup ftue code and ensure verification confirmation is displayed
2 parents 30e91e7 + dcec958 commit c646d6d

File tree

5 files changed

+119
-103
lines changed

5 files changed

+119
-103
lines changed

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

Lines changed: 13 additions & 26 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,28 +29,25 @@ 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)
4845
@Inject
4946
class FtueFlowNode(
5047
@Assisted buildContext: BuildContext,
5148
@Assisted plugins: List<Plugin>,
52-
private val ftueState: DefaultFtueService,
49+
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-
ftueState.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 (ftueState.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: 39 additions & 32 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,34 +26,37 @@ 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
30-
import kotlinx.coroutines.flow.filter
3131
import kotlinx.coroutines.flow.first
3232
import kotlinx.coroutines.flow.launchIn
33-
import kotlinx.coroutines.flow.map
3433
import kotlinx.coroutines.flow.onEach
34+
import kotlinx.coroutines.launch
3535

3636
@ContributesBinding(SessionScope::class)
3737
@SingleIn(SessionScope::class)
3838
@Inject
3939
class DefaultFtueService(
4040
private val sdkVersionProvider: BuildVersionSdkIntProvider,
41-
@SessionCoroutineScope sessionCoroutineScope: CoroutineScope,
41+
@SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope,
4242
private val analyticsService: AnalyticsService,
4343
private val permissionStateProvider: PermissionStateProvider,
4444
private val lockScreenService: LockScreenService,
4545
private val sessionVerificationService: SessionVerificationService,
4646
private val sessionPreferencesStore: SessionPreferencesStore,
4747
) : FtueService {
48-
override val state = MutableStateFlow<FtueState>(FtueState.Unknown)
48+
private val userNeedsToConfirmSessionVerificationSuccess = MutableStateFlow(false)
4949

50-
/**
51-
* This flow emits true when the FTUE flow is ready to be displayed.
52-
* In this case, the FTUE flow is ready when the session verification status is known.
53-
*/
54-
val isVerificationStatusKnown = sessionVerificationService.sessionVerifiedStatus
55-
.map { it != SessionVerifiedStatus.Unknown }
56-
.distinctUntilChanged()
50+
val ftueStepStateFlow = MutableStateFlow<InternalFtueState>(InternalFtueState.Unknown)
51+
52+
override val state = ftueStepStateFlow
53+
.mapState {
54+
when (it) {
55+
is InternalFtueState.Unknown -> FtueState.Unknown
56+
is InternalFtueState.Incomplete -> FtueState.Incomplete
57+
is InternalFtueState.Complete -> FtueState.Complete
58+
}
59+
}
5760

5861
override suspend fun reset() {
5962
analyticsService.reset()
@@ -63,24 +66,37 @@ class DefaultFtueService(
6366
}
6467

6568
init {
66-
sessionVerificationService.sessionVerifiedStatus
67-
.onEach { updateState() }
69+
combine(
70+
sessionVerificationService.sessionVerifiedStatus.onEach { sessionVerifiedStatus ->
71+
if (sessionVerifiedStatus == SessionVerifiedStatus.NotVerified) {
72+
// Ensure we wait for the user to confirm the session verified screen before going further
73+
userNeedsToConfirmSessionVerificationSuccess.value = true
74+
}
75+
},
76+
userNeedsToConfirmSessionVerificationSuccess,
77+
analyticsService.didAskUserConsentFlow.distinctUntilChanged(),
78+
) {
79+
updateFtueStep()
80+
}
6881
.launchIn(sessionCoroutineScope)
82+
}
6983

70-
analyticsService.didAskUserConsentFlow
71-
.distinctUntilChanged()
72-
.onEach { updateState() }
73-
.launchIn(sessionCoroutineScope)
84+
fun updateFtueStep() = sessionCoroutineScope.launch {
85+
val step = getNextStep(null)
86+
ftueStepStateFlow.value = when (step) {
87+
null -> InternalFtueState.Complete
88+
else -> InternalFtueState.Incomplete(step)
89+
}
7490
}
7591

76-
suspend fun getNextStep(currentStep: FtueStep? = null): FtueStep? =
77-
when (currentStep) {
92+
private suspend fun getNextStep(completedStep: FtueStep? = null): FtueStep? =
93+
when (completedStep) {
7894
null -> if (!isSessionVerificationStateReady()) {
7995
FtueStep.WaitingForInitialState
8096
} else {
8197
getNextStep(FtueStep.WaitingForInitialState)
8298
}
83-
FtueStep.WaitingForInitialState -> if (isSessionNotVerified()) {
99+
FtueStep.WaitingForInitialState -> if (isSessionNotVerified() || userNeedsToConfirmSessionVerificationSuccess.value) {
84100
FtueStep.SessionVerification
85101
} else {
86102
getNextStep(FtueStep.SessionVerification)
@@ -108,9 +124,6 @@ class DefaultFtueService(
108124
}
109125

110126
private suspend fun isSessionNotVerified(): Boolean {
111-
// Wait until the session verification status is known
112-
isVerificationStatusKnown.filter { it }.first()
113-
114127
return sessionVerificationService.sessionVerifiedStatus.value == SessionVerifiedStatus.NotVerified && !canSkipVerification()
115128
}
116129

@@ -137,14 +150,8 @@ class DefaultFtueService(
137150
return lockScreenService.isSetupRequired().first()
138151
}
139152

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-
}
153+
fun onUserCompletedSessionVerification() {
154+
userNeedsToConfirmSessionVerificationSuccess.value = false
148155
}
149156
}
150157

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: 1 addition & 3 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
@@ -36,8 +35,7 @@ class DefaultFtueEntryPointTest {
3635
buildContext = buildContext,
3736
plugins = plugins,
3837
analyticsEntryPoint = { _, _ -> lambdaError() },
39-
ftueState = createDefaultFtueService(),
40-
analyticsService = FakeAnalyticsService(),
38+
defaultFtueService = createDefaultFtueService(),
4139
lockScreenEntryPoint = object : LockScreenEntryPoint {
4240
override fun nodeBuilder(
4341
parentNode: com.bumble.appyx.core.node.Node,

0 commit comments

Comments
 (0)