Skip to content

Commit 2206e94

Browse files
jmartinespbmarty
andauthored
Fix verification failed issue, simplify verification logic (#3830)
* Simplify session verification: - Reuse Rust `Client` instances created on the login process so we don't need to restore one right before the session verification. - Remove unnecessary sources of verification state updates. - Add an intermediate FTUE flow step which will display an indeterminate progress indicator instead of a blank screen. * Remove unnecessary workaround: the SDK should already handle this * Add regression tests for noop analytics service usage. * Add `services.analytics.noop` module to the test dependencies --------- Co-authored-by: Benoit Marty <[email protected]>
1 parent cf9dbc6 commit 2206e94

File tree

11 files changed

+176
-113
lines changed

11 files changed

+176
-113
lines changed

appnav/src/main/kotlin/io/element/android/appnav/di/MatrixClientsHolder.kt

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,18 @@ private const val SAVE_INSTANCE_KEY = "io.element.android.x.di.MatrixClientsHold
2727

2828
@SingleIn(AppScope::class)
2929
@ContributesBinding(AppScope::class)
30-
class MatrixClientsHolder @Inject constructor(private val authenticationService: MatrixAuthenticationService) : MatrixClientProvider {
30+
class MatrixClientsHolder @Inject constructor(
31+
private val authenticationService: MatrixAuthenticationService,
32+
) : MatrixClientProvider {
3133
private val sessionIdsToMatrixClient = ConcurrentHashMap<SessionId, MatrixClient>()
3234
private val restoreMutex = Mutex()
3335

36+
init {
37+
authenticationService.listenToNewMatrixClients { matrixClient ->
38+
sessionIdsToMatrixClient[matrixClient.sessionId] = matrixClient
39+
}
40+
}
41+
3442
fun removeAll() {
3543
sessionIdsToMatrixClient.clear()
3644
}

appnav/src/test/kotlin/io/element/android/appnav/di/MatrixClientsHolderTest.kt

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,4 +81,17 @@ class MatrixClientsHolderTest {
8181
matrixClientsHolder.restoreWithSavedState(savedStateMap)
8282
assertThat(matrixClientsHolder.getOrNull(A_SESSION_ID)).isEqualTo(fakeMatrixClient)
8383
}
84+
85+
@Test
86+
fun `test AuthenticationService listenToNewMatrixClients emits a Client value and we save it`() = runTest {
87+
val fakeAuthenticationService = FakeMatrixAuthenticationService()
88+
val matrixClientsHolder = MatrixClientsHolder(fakeAuthenticationService)
89+
assertThat(matrixClientsHolder.getOrNull(A_SESSION_ID)).isNull()
90+
91+
fakeAuthenticationService.givenMatrixClient(FakeMatrixClient(sessionId = A_SESSION_ID))
92+
val loginSucceeded = fakeAuthenticationService.login("user", "pass")
93+
94+
assertThat(loginSucceeded.isSuccess).isTrue()
95+
assertThat(matrixClientsHolder.getOrNull(A_SESSION_ID)).isNotNull()
96+
}
8497
}

features/ftue/impl/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ dependencies {
4545
testImplementation(libs.test.turbine)
4646
testImplementation(projects.libraries.matrix.test)
4747
testImplementation(projects.services.analytics.test)
48+
testImplementation(projects.services.analytics.noop)
4849
testImplementation(projects.libraries.permissions.impl)
4950
testImplementation(projects.libraries.permissions.test)
5051
testImplementation(projects.libraries.preferences.test)

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

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,10 @@
88
package io.element.android.features.ftue.impl
99

1010
import android.os.Parcelable
11+
import androidx.compose.foundation.layout.Box
12+
import androidx.compose.foundation.layout.fillMaxSize
1113
import androidx.compose.runtime.Composable
14+
import androidx.compose.ui.Alignment
1215
import androidx.compose.ui.Modifier
1316
import androidx.lifecycle.lifecycleScope
1417
import com.bumble.appyx.core.lifecycle.subscribe
@@ -31,12 +34,14 @@ import io.element.android.features.lockscreen.api.LockScreenEntryPoint
3134
import io.element.android.libraries.architecture.BackstackView
3235
import io.element.android.libraries.architecture.BaseFlowNode
3336
import io.element.android.libraries.architecture.createNode
37+
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
3438
import io.element.android.libraries.di.AppScope
3539
import io.element.android.libraries.di.SessionScope
3640
import io.element.android.services.analytics.api.AnalyticsService
3741
import kotlinx.coroutines.flow.MutableStateFlow
3842
import kotlinx.coroutines.flow.StateFlow
3943
import kotlinx.coroutines.flow.distinctUntilChanged
44+
import kotlinx.coroutines.flow.filter
4045
import kotlinx.coroutines.flow.launchIn
4146
import kotlinx.coroutines.flow.onEach
4247
import kotlinx.coroutines.launch
@@ -80,14 +85,17 @@ class FtueFlowNode @AssistedInject constructor(
8085
super.onBuilt()
8186

8287
lifecycle.subscribe(onCreate = {
83-
lifecycleScope.launch { moveToNextStep() }
88+
moveToNextStepIfNeeded()
8489
})
8590

8691
analyticsService.didAskUserConsent()
8792
.distinctUntilChanged()
88-
.onEach {
89-
lifecycleScope.launch { moveToNextStep() }
90-
}
93+
.onEach { moveToNextStepIfNeeded() }
94+
.launchIn(lifecycleScope)
95+
96+
ftueState.isVerificationStatusKnown
97+
.filter { it }
98+
.onEach { moveToNextStepIfNeeded() }
9199
.launchIn(lifecycleScope)
92100
}
93101

@@ -99,15 +107,15 @@ class FtueFlowNode @AssistedInject constructor(
99107
NavTarget.SessionVerification -> {
100108
val callback = object : FtueSessionVerificationFlowNode.Callback {
101109
override fun onDone() {
102-
lifecycleScope.launch { moveToNextStep() }
110+
moveToNextStepIfNeeded()
103111
}
104112
}
105113
createNode<FtueSessionVerificationFlowNode>(buildContext, listOf(callback))
106114
}
107115
NavTarget.NotificationsOptIn -> {
108116
val callback = object : NotificationsOptInNode.Callback {
109117
override fun onNotificationsOptInFinished() {
110-
lifecycleScope.launch { moveToNextStep() }
118+
moveToNextStepIfNeeded()
111119
}
112120
}
113121
createNode<NotificationsOptInNode>(buildContext, listOf(callback))
@@ -118,7 +126,7 @@ class FtueFlowNode @AssistedInject constructor(
118126
NavTarget.LockScreenSetup -> {
119127
val callback = object : LockScreenEntryPoint.Callback {
120128
override fun onSetupDone() {
121-
lifecycleScope.launch { moveToNextStep() }
129+
moveToNextStepIfNeeded()
122130
}
123131
}
124132
lockScreenEntryPoint.nodeBuilder(this, buildContext, LockScreenEntryPoint.Target.Setup)
@@ -128,8 +136,11 @@ class FtueFlowNode @AssistedInject constructor(
128136
}
129137
}
130138

131-
private fun moveToNextStep() = lifecycleScope.launch {
139+
private fun moveToNextStepIfNeeded() = lifecycleScope.launch {
132140
when (ftueState.getNextStep()) {
141+
FtueStep.WaitingForInitialState -> {
142+
backstack.newRoot(NavTarget.Placeholder)
143+
}
133144
FtueStep.SessionVerification -> {
134145
backstack.newRoot(NavTarget.SessionVerification)
135146
}
@@ -155,7 +166,14 @@ class FtueFlowNode @AssistedInject constructor(
155166
class PlaceholderNode @AssistedInject constructor(
156167
@Assisted buildContext: BuildContext,
157168
@Assisted plugins: List<Plugin>,
158-
) : Node(buildContext, plugins = plugins)
169+
) : Node(buildContext, plugins = plugins) {
170+
@Composable
171+
override fun View(modifier: Modifier) {
172+
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
173+
CircularProgressIndicator()
174+
}
175+
}
176+
}
159177
}
160178

161179
private class NoOpBackstackHandlerStrategy<NavTarget : Any> : BaseBackPressHandlerStrategy<NavTarget, BackStack.State>() {

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

Lines changed: 27 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -24,18 +24,14 @@ import io.element.android.libraries.preferences.api.store.SessionPreferencesStor
2424
import io.element.android.services.analytics.api.AnalyticsService
2525
import io.element.android.services.toolbox.api.sdk.BuildVersionSdkIntProvider
2626
import kotlinx.coroutines.CoroutineScope
27-
import kotlinx.coroutines.FlowPreview
2827
import kotlinx.coroutines.flow.MutableStateFlow
29-
import kotlinx.coroutines.flow.catch
3028
import kotlinx.coroutines.flow.distinctUntilChanged
3129
import kotlinx.coroutines.flow.filter
3230
import kotlinx.coroutines.flow.first
3331
import kotlinx.coroutines.flow.launchIn
32+
import kotlinx.coroutines.flow.map
3433
import kotlinx.coroutines.flow.onEach
35-
import kotlinx.coroutines.flow.timeout
36-
import timber.log.Timber
3734
import javax.inject.Inject
38-
import kotlin.time.Duration.Companion.seconds
3935

4036
@ContributesBinding(SessionScope::class)
4137
@SingleIn(SessionScope::class)
@@ -50,6 +46,14 @@ class DefaultFtueService @Inject constructor(
5046
) : FtueService {
5147
override val state = MutableStateFlow<FtueState>(FtueState.Unknown)
5248

49+
/**
50+
* This flow emits true when the FTUE flow is ready to be displayed.
51+
* In this case, the FTUE flow is ready when the session verification status is known.
52+
*/
53+
val isVerificationStatusKnown = sessionVerificationService.sessionVerifiedStatus
54+
.map { it != SessionVerifiedStatus.Unknown }
55+
.distinctUntilChanged()
56+
5357
override suspend fun reset() {
5458
analyticsService.reset()
5559
if (sdkVersionProvider.isAtLeast(Build.VERSION_CODES.TIRAMISU)) {
@@ -70,7 +74,12 @@ class DefaultFtueService @Inject constructor(
7074

7175
suspend fun getNextStep(currentStep: FtueStep? = null): FtueStep? =
7276
when (currentStep) {
73-
null -> if (isSessionNotVerified()) {
77+
null -> if (!isSessionVerificationStateReady()) {
78+
FtueStep.WaitingForInitialState
79+
} else {
80+
getNextStep(FtueStep.WaitingForInitialState)
81+
}
82+
FtueStep.WaitingForInitialState -> if (isSessionNotVerified()) {
7483
FtueStep.SessionVerification
7584
} else {
7685
getNextStep(FtueStep.SessionVerification)
@@ -90,34 +99,18 @@ class DefaultFtueService @Inject constructor(
9099
} else {
91100
getNextStep(FtueStep.AnalyticsOptIn)
92101
}
93-
FtueStep.AnalyticsOptIn -> {
94-
updateState()
95-
null
96-
}
102+
FtueStep.AnalyticsOptIn -> null
97103
}
98104

99-
private suspend fun isAnyStepIncomplete(): Boolean {
100-
return listOf<suspend () -> Boolean>(
101-
{ isSessionNotVerified() },
102-
{ shouldAskNotificationPermissions() },
103-
{ needsAnalyticsOptIn() },
104-
{ shouldDisplayLockscreenSetup() },
105-
).any { it() }
105+
private fun isSessionVerificationStateReady(): Boolean {
106+
return sessionVerificationService.sessionVerifiedStatus.value != SessionVerifiedStatus.Unknown
106107
}
107108

108-
@OptIn(FlowPreview::class)
109109
private suspend fun isSessionNotVerified(): Boolean {
110-
// Wait for the first known (or ready) verification status
111-
val readyVerifiedSessionStatus = sessionVerificationService.sessionVerifiedStatus
112-
.filter { it != SessionVerifiedStatus.Unknown }
113-
// This is not ideal, but there are some very rare cases when reading the flow seems to get stuck
114-
.timeout(5.seconds)
115-
.catch {
116-
Timber.e(it, "Failed to get session verification status, assume it's not verified")
117-
emit(SessionVerifiedStatus.NotVerified)
118-
}
119-
.first()
120-
return readyVerifiedSessionStatus == SessionVerifiedStatus.NotVerified && !canSkipVerification()
110+
// Wait until the session verification status is known
111+
isVerificationStatusKnown.filter { it }.first()
112+
113+
return sessionVerificationService.sessionVerifiedStatus.value == SessionVerifiedStatus.NotVerified && !canSkipVerification()
121114
}
122115

123116
private suspend fun canSkipVerification(): Boolean {
@@ -145,14 +138,17 @@ class DefaultFtueService @Inject constructor(
145138

146139
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
147140
internal suspend fun updateState() {
141+
val nextStep = getNextStep()
148142
state.value = when {
149-
isAnyStepIncomplete() -> FtueState.Incomplete
150-
else -> FtueState.Complete
143+
// Final state, there aren't any more next steps
144+
nextStep == null -> FtueState.Complete
145+
else -> FtueState.Incomplete
151146
}
152147
}
153148
}
154149

155150
sealed interface FtueStep {
151+
data object WaitingForInitialState : FtueStep
156152
data object SessionVerification : FtueStep
157153
data object NotificationsOptIn : FtueStep
158154
data object AnalyticsOptIn : FtueStep

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

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import io.element.android.libraries.permissions.impl.FakePermissionStateProvider
2323
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
2424
import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore
2525
import io.element.android.services.analytics.api.AnalyticsService
26+
import io.element.android.services.analytics.noop.NoopAnalyticsService
2627
import io.element.android.services.analytics.test.FakeAnalyticsService
2728
import io.element.android.services.toolbox.test.sdk.FakeBuildVersionSdkIntProvider
2829
import io.element.android.tests.testutils.lambda.lambdaRecorder
@@ -73,6 +74,27 @@ class DefaultFtueServiceTest {
7374
assertThat(service.state.value).isEqualTo(FtueState.Complete)
7475
}
7576

77+
@Test
78+
fun `given all checks being true with no analytics, FtueState is Complete`() = runTest {
79+
val analyticsService = NoopAnalyticsService()
80+
val sessionVerificationService = FakeSessionVerificationService()
81+
val permissionStateProvider = FakePermissionStateProvider(permissionGranted = true)
82+
val lockScreenService = FakeLockScreenService()
83+
val service = createDefaultFtueService(
84+
sessionVerificationService = sessionVerificationService,
85+
analyticsService = analyticsService,
86+
permissionStateProvider = permissionStateProvider,
87+
lockScreenService = lockScreenService,
88+
)
89+
90+
sessionVerificationService.emitVerifiedStatus(SessionVerifiedStatus.Verified)
91+
permissionStateProvider.setPermissionGranted()
92+
lockScreenService.setIsPinSetup(true)
93+
service.updateState()
94+
95+
assertThat(service.state.value).isEqualTo(FtueState.Complete)
96+
}
97+
7698
@Test
7799
fun `traverse flow`() = runTest {
78100
val sessionVerificationService = FakeSessionVerificationService().apply {

libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/MatrixAuthenticationService.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,4 +56,7 @@ interface MatrixAuthenticationService {
5656
suspend fun loginWithOidc(callbackUrl: String): Result<SessionId>
5757

5858
suspend fun loginWithQrCode(qrCodeData: MatrixQrCodeLoginData, progress: (QrCodeLoginStep) -> Unit): Result<SessionId>
59+
60+
/** Listen to new Matrix clients being created on authentication. */
61+
fun listenToNewMatrixClients(lambda: (MatrixClient) -> Unit)
5962
}

libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactory.kt

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import io.element.android.services.analytics.api.AnalyticsService
2525
import io.element.android.services.toolbox.api.systemclock.SystemClock
2626
import kotlinx.coroutines.CoroutineScope
2727
import kotlinx.coroutines.withContext
28+
import org.matrix.rustcomponents.sdk.Client
2829
import org.matrix.rustcomponents.sdk.ClientBuilder
2930
import org.matrix.rustcomponents.sdk.Session
3031
import org.matrix.rustcomponents.sdk.SlidingSyncVersion
@@ -51,27 +52,31 @@ class RustMatrixClientFactory @Inject constructor(
5152
private val timelineEventTypeFilterFactory: TimelineEventTypeFilterFactory,
5253
private val clientBuilderProvider: ClientBuilderProvider,
5354
) {
55+
private val sessionDelegate = RustClientSessionDelegate(sessionStore, appCoroutineScope, coroutineDispatchers)
56+
5457
suspend fun create(sessionData: SessionData): RustMatrixClient = withContext(coroutineDispatchers.io) {
55-
val sessionDelegate = RustClientSessionDelegate(sessionStore, appCoroutineScope, coroutineDispatchers)
5658
val client = getBaseClientBuilder(
5759
sessionPaths = sessionData.getSessionPaths(),
5860
passphrase = sessionData.passphrase,
5961
slidingSyncType = ClientBuilderSlidingSync.Restored,
6062
)
6163
.homeserverUrl(sessionData.homeserverUrl)
6264
.username(sessionData.userId)
63-
.setSessionDelegate(sessionDelegate)
6465
.use { it.build() }
6566

6667
client.restoreSession(sessionData.toSession())
6768

69+
create(client)
70+
}
71+
72+
suspend fun create(client: Client): RustMatrixClient {
73+
val (anonymizedAccessToken, anonymizedRefreshToken) = client.session().anonymizedTokens()
74+
6875
val syncService = client.syncService()
6976
.withUtdHook(UtdTracker(analyticsService))
7077
.finish()
7178

72-
val (anonymizedAccessToken, anonymizedRefreshToken) = sessionData.anonymizedTokens()
73-
74-
RustMatrixClient(
79+
return RustMatrixClient(
7580
client = client,
7681
baseDirectory = baseDirectory,
7782
sessionStore = sessionStore,
@@ -98,6 +103,7 @@ class RustMatrixClientFactory @Inject constructor(
98103
dataPath = sessionPaths.fileDirectory.absolutePath,
99104
cachePath = sessionPaths.cacheDirectory.absolutePath,
100105
)
106+
.setSessionDelegate(sessionDelegate)
101107
.passphrase(passphrase)
102108
.userAgent(userAgentProvider.provide())
103109
.addRootCertificates(userCertificatesProvider.provides())

0 commit comments

Comments
 (0)