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 stop()
fun requestSession()
fun requestAvatar(userId: UserId)
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()")
coroutineScope.launch {
val finalMessenger = messenger
@ -225,6 +224,11 @@ class DefaultElementClassicConnection(
coroutineScope.launch {
val updatedState = ensureHomeserverIsSupported(state)
emitState(updatedState)
val userId = (updatedState as? ElementClassicConnectionState.ElementClassicReady)?.elementClassicSession?.userId
if (userId != null) {
// Step 2, request the avatar
requestAvatar(userId)
}
}
}
@ -241,6 +245,9 @@ class DefaultElementClassicConnection(
)
} else {
val avatar = BundleCompat.getParcelable(data, KEY_USER_AVATAR_PARCELABLE, Bitmap::class.java)
// If the avatar is identical to the current one, do not emit a new state to avoid unnecessary recompositions
// and blink on the avatar image
if (avatar == null || !avatar.sameAs(currentState.avatar)) {
val updatedState = currentState.copy(
avatar = avatar,
)
@ -248,6 +255,7 @@ class DefaultElementClassicConnection(
emitState(updatedState)
}
}
}
} else {
Timber.tag(loggerTag.value).w("Received profile data but current state is not ElementClassicReady: %s", currentState)
}
@ -343,6 +351,10 @@ class DefaultElementClassicConnection(
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(
elementClassicSession = ElementClassicSession(
userId = userId,
@ -352,7 +364,7 @@ class DefaultElementClassicConnection(
doesContainBackupKey = doesContainBackupKey,
),
displayName = displayName,
avatar = null,
avatar = currentAvatar,
)
}
}

View file

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

View file

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

View file

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

View file

@ -56,13 +56,6 @@ class LoginWithClassicPresenter(
fun handleEvent(event: LoginWithClassicEvent) {
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 -> {
val currentState = elementClassicConnection.stateFlow.value
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.tooling.preview.PreviewParameter
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.features.login.impl.R
import io.element.android.features.login.impl.login.LoginModeView
@ -67,10 +65,6 @@ fun LoginWithClassicView(
onCreateAccountContinue: (url: String) -> Unit,
modifier: Modifier = Modifier,
) {
LifecycleEventEffect(Lifecycle.Event.ON_RESUME) {
state.eventSink(LoginWithClassicEvent.RefreshData)
}
val isLoading by remember(state.loginMode) {
derivedStateOf {
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
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 io.element.android.features.login.impl.classic.ElementClassicConnection
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.meta.BuildMeta
@Inject
class MissingKeyBackupPresenter(
private val buildMeta: BuildMeta,
private val elementClassicConnection: ElementClassicConnection,
) : Presenter<MissingKeyBackupState> {
@Composable
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(
appName = buildMeta.applicationName,
eventSink = ::handleEvent,
)
}
}

View file

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

View file

@ -19,8 +19,6 @@ open class MissingKeyBackupStateProvider : PreviewParameterProvider<MissingKeyBa
fun aMissingKeyBackupState(
appName: String = "AppName",
eventSink: (MissingKeyBackupEvent) -> Unit = {},
) = MissingKeyBackupState(
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.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.login.impl.R
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.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.utils.OnLifecycleEvent
import kotlinx.collections.immutable.persistentListOf
@Composable
@ -35,11 +33,6 @@ fun MissingKeyBackupView(
onOpenClassicClick: () -> Unit,
modifier: Modifier = Modifier,
) {
OnLifecycleEvent { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
state.eventSink.invoke(MissingKeyBackupEvent.OnResume)
}
}
FlowStepPage(
modifier = modifier,
onBackClick = onBackClick,

View file

@ -7,7 +7,6 @@
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 kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@ -17,13 +16,11 @@ class FakeElementClassicConnection(
private val startResult: () -> Unit = { lambdaError() },
private val stopResult: () -> Unit = { lambdaError() },
private val requestSessionResult: () -> Unit = { lambdaError() },
private val requestAvatarResult: (UserId) -> Unit = { lambdaError() },
initialState: ElementClassicConnectionState = ElementClassicConnectionState.Idle
) : ElementClassicConnection {
override fun start() = startResult()
override fun stop() = stopResult()
override fun requestSession() = requestSessionResult()
override fun requestAvatar(userId: UserId) = requestAvatarResult(userId)
private val mutableStateFlow = MutableStateFlow(initialState)
override val stateFlow: StateFlow<ElementClassicConnectionState> = mutableStateFlow.asStateFlow()
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
fun `present - start login with correct state - user can login`() = runTest {
val authenticationService = FakeMatrixAuthenticationService(

View file

@ -8,12 +8,9 @@
package io.element.android.features.login.impl.screens.classic.missingkeybackup
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.matrix.test.AN_APPLICATION_NAME
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 kotlinx.coroutines.test.runTest
import org.junit.Test
@ -27,29 +24,10 @@ class MissingKeyBackupPresenterTest {
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(
buildMeta: BuildMeta = aBuildMeta(applicationName = AN_APPLICATION_NAME),
elementClassicConnection: ElementClassicConnection = FakeElementClassicConnection(),
) = MissingKeyBackupPresenter(
buildMeta = buildMeta,
elementClassicConnection = elementClassicConnection,
)