Refresh Element Classic state each time ClassicFlowNode is resumed.

This ensure that Element X is always up to date regarding Element Classic state.
This commit is contained in:
Benoit Marty 2026-04-14 16:51:00 +02:00
parent 76de9db94e
commit fd3c4c2b2b
14 changed files with 30 additions and 119 deletions

View file

@ -43,7 +43,6 @@ interface ElementClassicConnection {
fun start() fun start()
fun stop() fun stop()
fun requestSession() fun requestSession()
fun requestAvatar(userId: UserId)
val stateFlow: StateFlow<ElementClassicConnectionState> val stateFlow: StateFlow<ElementClassicConnectionState>
} }
@ -174,7 +173,7 @@ class DefaultElementClassicConnection(
} }
} }
override fun requestAvatar(userId: UserId) { private fun requestAvatar(userId: UserId) {
Timber.tag(loggerTag.value).d("requestAvatar()") Timber.tag(loggerTag.value).d("requestAvatar()")
coroutineScope.launch { coroutineScope.launch {
val finalMessenger = messenger val finalMessenger = messenger
@ -225,6 +224,11 @@ class DefaultElementClassicConnection(
coroutineScope.launch { coroutineScope.launch {
val updatedState = ensureHomeserverIsSupported(state) val updatedState = ensureHomeserverIsSupported(state)
emitState(updatedState) emitState(updatedState)
val userId = (updatedState as? ElementClassicConnectionState.ElementClassicReady)?.elementClassicSession?.userId
if (userId != null) {
// Step 2, request the avatar
requestAvatar(userId)
}
} }
} }
@ -241,11 +245,15 @@ class DefaultElementClassicConnection(
) )
} else { } else {
val avatar = BundleCompat.getParcelable(data, KEY_USER_AVATAR_PARCELABLE, Bitmap::class.java) val avatar = BundleCompat.getParcelable(data, KEY_USER_AVATAR_PARCELABLE, Bitmap::class.java)
val updatedState = currentState.copy( // If the avatar is identical to the current one, do not emit a new state to avoid unnecessary recompositions
avatar = avatar, // and blink on the avatar image
) if (avatar == null || !avatar.sameAs(currentState.avatar)) {
coroutineScope.launch { val updatedState = currentState.copy(
emitState(updatedState) avatar = avatar,
)
coroutineScope.launch {
emitState(updatedState)
}
} }
} }
} else { } else {
@ -343,6 +351,10 @@ class DefaultElementClassicConnection(
append(doesContainBackupKey) append(doesContainBackupKey)
} }
) )
// Ensure avatar is not lost when refreshing the data
val currentAvatar = (stateFlow.value as? ElementClassicConnectionState.ElementClassicReady)
?.takeIf { it.elementClassicSession.userId == userId }
?.avatar
ElementClassicConnectionState.ElementClassicReady( ElementClassicConnectionState.ElementClassicReady(
elementClassicSession = ElementClassicSession( elementClassicSession = ElementClassicSession(
userId = userId, userId = userId,
@ -352,7 +364,7 @@ class DefaultElementClassicConnection(
doesContainBackupKey = doesContainBackupKey, doesContainBackupKey = doesContainBackupKey,
), ),
displayName = displayName, displayName = displayName,
avatar = null, avatar = currentAvatar,
) )
} }
} }

View file

@ -11,6 +11,7 @@ import android.os.Parcelable
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.bumble.appyx.core.lifecycle.subscribe
import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin import com.bumble.appyx.core.plugin.Plugin
@ -75,6 +76,11 @@ class ClassicFlowNode(
override fun onBuilt() { override fun onBuilt() {
super.onBuilt() super.onBuilt()
observeElementClassicConnection() observeElementClassicConnection()
lifecycle.subscribe(
onResume = {
classicFlowNodeHelper.onResume()
},
)
} }
private fun observeElementClassicConnection() { private fun observeElementClassicConnection() {

View file

@ -26,6 +26,10 @@ class ClassicFlowNodeHelper(
private val elementClassicConnection: ElementClassicConnection, private val elementClassicConnection: ElementClassicConnection,
private val sessionStore: SessionStore, private val sessionStore: SessionStore,
) { ) {
fun onResume() {
elementClassicConnection.requestSession()
}
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
fun navigationEventFlow(): Flow<NavigationEvent> { fun navigationEventFlow(): Flow<NavigationEvent> {
return elementClassicConnection.stateFlow return elementClassicConnection.stateFlow

View file

@ -8,7 +8,6 @@
package io.element.android.features.login.impl.screens.classic.loginwithclassic package io.element.android.features.login.impl.screens.classic.loginwithclassic
sealed interface LoginWithClassicEvent { sealed interface LoginWithClassicEvent {
data object RefreshData : LoginWithClassicEvent
data object Submit : LoginWithClassicEvent data object Submit : LoginWithClassicEvent
data object ClearError : LoginWithClassicEvent data object ClearError : LoginWithClassicEvent
} }

View file

@ -56,13 +56,6 @@ class LoginWithClassicPresenter(
fun handleEvent(event: LoginWithClassicEvent) { fun handleEvent(event: LoginWithClassicEvent) {
when (event) { when (event) {
LoginWithClassicEvent.RefreshData -> {
// Request the avatar if not known yet
val currentState = elementClassicConnection.stateFlow.value
if ((currentState as? ElementClassicConnectionState.ElementClassicReady)?.avatar == null) {
elementClassicConnection.requestAvatar(userId)
}
}
LoginWithClassicEvent.Submit -> { LoginWithClassicEvent.Submit -> {
val currentState = elementClassicConnection.stateFlow.value val currentState = elementClassicConnection.stateFlow.value
if (currentState is ElementClassicConnectionState.ElementClassicReady) { if (currentState is ElementClassicConnectionState.ElementClassicReady) {

View file

@ -33,8 +33,6 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LifecycleEventEffect
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.features.login.impl.R import io.element.android.features.login.impl.R
import io.element.android.features.login.impl.login.LoginModeView import io.element.android.features.login.impl.login.LoginModeView
@ -67,10 +65,6 @@ fun LoginWithClassicView(
onCreateAccountContinue: (url: String) -> Unit, onCreateAccountContinue: (url: String) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
LifecycleEventEffect(Lifecycle.Event.ON_RESUME) {
state.eventSink(LoginWithClassicEvent.RefreshData)
}
val isLoading by remember(state.loginMode) { val isLoading by remember(state.loginMode) {
derivedStateOf { derivedStateOf {
state.loginMode is AsyncData.Loading state.loginMode is AsyncData.Loading

View file

@ -1,12 +0,0 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.login.impl.screens.classic.missingkeybackup
sealed interface MissingKeyBackupEvent {
data object OnResume : MissingKeyBackupEvent
}

View file

@ -8,38 +8,18 @@
package io.element.android.features.login.impl.screens.classic.missingkeybackup package io.element.android.features.login.impl.screens.classic.missingkeybackup
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Inject import dev.zacsweers.metro.Inject
import io.element.android.features.login.impl.classic.ElementClassicConnection
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.core.meta.BuildMeta
@Inject @Inject
class MissingKeyBackupPresenter( class MissingKeyBackupPresenter(
private val buildMeta: BuildMeta, private val buildMeta: BuildMeta,
private val elementClassicConnection: ElementClassicConnection,
) : Presenter<MissingKeyBackupState> { ) : Presenter<MissingKeyBackupState> {
@Composable @Composable
override fun present(): MissingKeyBackupState { override fun present(): MissingKeyBackupState {
var resumeCounter by remember { mutableIntStateOf(0) }
fun handleEvent(event: MissingKeyBackupEvent) {
when (event) {
MissingKeyBackupEvent.OnResume -> {
resumeCounter++
if (resumeCounter > 1) {
// The user has returned to this screen, we can assume they have gone to the backup flow and are now back here
elementClassicConnection.requestSession()
}
}
}
}
return MissingKeyBackupState( return MissingKeyBackupState(
appName = buildMeta.applicationName, appName = buildMeta.applicationName,
eventSink = ::handleEvent,
) )
} }
} }

View file

@ -9,5 +9,4 @@ package io.element.android.features.login.impl.screens.classic.missingkeybackup
data class MissingKeyBackupState( data class MissingKeyBackupState(
val appName: String, val appName: String,
val eventSink: (MissingKeyBackupEvent) -> Unit
) )

View file

@ -19,8 +19,6 @@ open class MissingKeyBackupStateProvider : PreviewParameterProvider<MissingKeyBa
fun aMissingKeyBackupState( fun aMissingKeyBackupState(
appName: String = "AppName", appName: String = "AppName",
eventSink: (MissingKeyBackupEvent) -> Unit = {},
) = MissingKeyBackupState( ) = MissingKeyBackupState(
appName = appName, appName = appName,
eventSink = eventSink
) )

View file

@ -16,7 +16,6 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.login.impl.R import io.element.android.features.login.impl.R
import io.element.android.libraries.designsystem.atomic.organisms.NumberedListOrganism import io.element.android.libraries.designsystem.atomic.organisms.NumberedListOrganism
@ -25,7 +24,6 @@ import io.element.android.libraries.designsystem.components.BigIcon
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Button import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.utils.OnLifecycleEvent
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
@Composable @Composable
@ -35,11 +33,6 @@ fun MissingKeyBackupView(
onOpenClassicClick: () -> Unit, onOpenClassicClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
OnLifecycleEvent { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
state.eventSink.invoke(MissingKeyBackupEvent.OnResume)
}
}
FlowStepPage( FlowStepPage(
modifier = modifier, modifier = modifier,
onBackClick = onBackClick, onBackClick = onBackClick,

View file

@ -7,7 +7,6 @@
package io.element.android.features.login.impl.classic package io.element.android.features.login.impl.classic
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.tests.testutils.lambda.lambdaError import io.element.android.tests.testutils.lambda.lambdaError
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@ -17,13 +16,11 @@ class FakeElementClassicConnection(
private val startResult: () -> Unit = { lambdaError() }, private val startResult: () -> Unit = { lambdaError() },
private val stopResult: () -> Unit = { lambdaError() }, private val stopResult: () -> Unit = { lambdaError() },
private val requestSessionResult: () -> Unit = { lambdaError() }, private val requestSessionResult: () -> Unit = { lambdaError() },
private val requestAvatarResult: (UserId) -> Unit = { lambdaError() },
initialState: ElementClassicConnectionState = ElementClassicConnectionState.Idle initialState: ElementClassicConnectionState = ElementClassicConnectionState.Idle
) : ElementClassicConnection { ) : ElementClassicConnection {
override fun start() = startResult() override fun start() = startResult()
override fun stop() = stopResult() override fun stop() = stopResult()
override fun requestSession() = requestSessionResult() override fun requestSession() = requestSessionResult()
override fun requestAvatar(userId: UserId) = requestAvatarResult(userId)
private val mutableStateFlow = MutableStateFlow(initialState) private val mutableStateFlow = MutableStateFlow(initialState)
override val stateFlow: StateFlow<ElementClassicConnectionState> = mutableStateFlow.asStateFlow() override val stateFlow: StateFlow<ElementClassicConnectionState> = mutableStateFlow.asStateFlow()
suspend fun emitState(state: ElementClassicConnectionState) { suspend fun emitState(state: ElementClassicConnectionState) {

View file

@ -57,36 +57,6 @@ class LoginWithClassicPresenterTest {
} }
} }
@Test
fun `present - refresh data invokes the expected methods`() = runTest {
val requestAvatarResult = lambdaRecorder<UserId, Unit> { }
val elementClassicConnection = FakeElementClassicConnection(
startResult = {},
requestAvatarResult = requestAvatarResult,
)
val presenter = createPresenter(
elementClassicConnection = elementClassicConnection,
)
presenter.test {
skipItems(1)
elementClassicConnection.emitState(
anElementClassicReady(
elementClassicSession = anElementClassicSession(
userId = A_USER_ID,
secrets = A_SECRET,
roomKeysVersion = ROOM_KEYS_VERSION,
),
displayName = A_USER_NAME,
)
)
val readyState = awaitItem()
assertThat(readyState.userId).isEqualTo(A_USER_ID)
assertThat(readyState.displayName).isEqualTo(A_USER_NAME)
readyState.eventSink(LoginWithClassicEvent.RefreshData)
requestAvatarResult.assertions().isCalledOnce()
}
}
@Test @Test
fun `present - start login with correct state - user can login`() = runTest { fun `present - start login with correct state - user can login`() = runTest {
val authenticationService = FakeMatrixAuthenticationService( val authenticationService = FakeMatrixAuthenticationService(

View file

@ -8,12 +8,9 @@
package io.element.android.features.login.impl.screens.classic.missingkeybackup package io.element.android.features.login.impl.screens.classic.missingkeybackup
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.features.login.impl.classic.ElementClassicConnection
import io.element.android.features.login.impl.classic.FakeElementClassicConnection
import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.matrix.test.AN_APPLICATION_NAME import io.element.android.libraries.matrix.test.AN_APPLICATION_NAME
import io.element.android.libraries.matrix.test.core.aBuildMeta import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.test import io.element.android.tests.testutils.test
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
@ -27,29 +24,10 @@ class MissingKeyBackupPresenterTest {
assertThat(initialState.appName).isEqualTo(AN_APPLICATION_NAME) assertThat(initialState.appName).isEqualTo(AN_APPLICATION_NAME)
} }
} }
@Test
fun `present - when the screen is resumed twice, the start over method is called`() = runTest {
val requestSessionResult = lambdaRecorder<Unit> { }
val presenter = createPresenter(
elementClassicConnection = FakeElementClassicConnection(
requestSessionResult = requestSessionResult,
),
)
presenter.test {
val initialState = awaitItem()
initialState.eventSink(MissingKeyBackupEvent.OnResume)
expectNoEvents()
initialState.eventSink(MissingKeyBackupEvent.OnResume)
requestSessionResult.assertions().isCalledOnce()
}
}
} }
private fun createPresenter( private fun createPresenter(
buildMeta: BuildMeta = aBuildMeta(applicationName = AN_APPLICATION_NAME), buildMeta: BuildMeta = aBuildMeta(applicationName = AN_APPLICATION_NAME),
elementClassicConnection: ElementClassicConnection = FakeElementClassicConnection(),
) = MissingKeyBackupPresenter( ) = MissingKeyBackupPresenter(
buildMeta = buildMeta, buildMeta = buildMeta,
elementClassicConnection = elementClassicConnection,
) )