Merge branch 'develop' into feature/bma/otherAccountUnreadCount

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benoit Marty
2026-10-14 20:15:02 +02:00
131 changed files with 1007 additions and 468 deletions
@@ -46,6 +46,7 @@ import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.mediaviewer.api.FileViewerEntryPoint
import io.element.android.libraries.troubleshoot.api.NotificationTroubleShootEntryPoint
@@ -141,6 +142,10 @@ class PreferencesFlowNode(
callback.navigateToAddAccount()
}
override fun navigateToSession(sessionId: SessionId) {
callback.navigateToSession(sessionId)
}
override fun navigateToAnalyticsSettings() {
backstack.push(NavTarget.AnalyticsSettings)
}
@@ -26,6 +26,7 @@ import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runUpdatingStateNoSuccess
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.featureflag.api.FeatureFlagService
@@ -57,6 +58,7 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import timber.log.Timber
import kotlin.time.Duration.Companion.seconds
@@ -76,6 +78,7 @@ class NotificationSettingsPresenter(
@SessionCoroutineScope
private val sessionCoroutineScope: CoroutineScope,
private val featureFlagService: FeatureFlagService,
private val dispatchers: CoroutineDispatchers,
) : Presenter<NotificationSettingsState> {
// Serializes the pick → copy → persist → recreate pipeline per slot. The copier already locks
// its `<slot>.tmp` file, but the persist+recreate window outside the copier is unprotected;
@@ -111,16 +114,19 @@ class NotificationSettingsPresenter(
}
// List of PushProvider -> Distributor
val distributors = remember {
pushService.getAvailablePushProviders()
.flatMap { pushProvider ->
pushProvider.getDistributors().map { distributor ->
pushProvider to distributor
// Getting the distributors can query the PackageManager and the Play Services, so do it off the main thread
val distributors by produceState(emptyList<Pair<PushProvider, Distributor>>()) {
value = withContext(dispatchers.io) {
pushService.getAvailablePushProviders()
.flatMap { pushProvider ->
pushProvider.getDistributors().map { distributor ->
pushProvider to distributor
}
}
}
}
}
// List of Distributors
val availableDistributors = remember {
val availableDistributors = remember(distributors) {
distributors.map { it.second }.toImmutableList()
}
@@ -8,11 +8,8 @@
package io.element.android.features.preferences.impl.root
import io.element.android.libraries.matrix.api.core.SessionId
sealed interface PreferencesRootEvent {
data object OnVersionInfoClick : PreferencesRootEvent
data class SwitchToSession(val sessionId: SessionId) : PreferencesRootEvent
data object ToggleOtherAccountsExpanded : PreferencesRootEvent
data class SetTheme(val theme: ThemeOption) : PreferencesRootEvent
}
@@ -24,6 +24,7 @@ import io.element.android.libraries.androidutils.browser.openUrlInChromeCustomTa
import io.element.android.libraries.architecture.callback
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.emoji.api.picker.EmojiPickerRenderer
import io.element.android.libraries.matrix.api.core.SessionId
@ContributesNode(SessionScope::class)
@AssistedInject
@@ -35,6 +36,7 @@ class PreferencesRootNode(
) : Node(buildContext, plugins = plugins) {
interface Callback : PreferencesAccountCallback, Plugin {
fun navigateToAddAccount()
fun navigateToSession(sessionId: SessionId)
fun navigateToAnalyticsSettings()
fun navigateToAbout()
fun navigateToDeveloperSettings()
@@ -72,6 +74,7 @@ class PreferencesRootNode(
modifier = modifier,
onBackClick = this::navigateUp,
onAddAccountClick = callback::navigateToAddAccount,
onSwitchToSession = callback::navigateToSession,
onOpenAnalytics = callback::navigateToAnalyticsSettings,
onOpenAbout = callback::navigateToAbout,
onOpenDeveloperSettings = callback::navigateToDeveloperSettings,
@@ -142,9 +142,6 @@ class PreferencesRootPresenter(
is PreferencesRootEvent.OnVersionInfoClick -> {
showDeveloperSettingsProvider.unlockDeveloperSettings(coroutineScope)
}
is PreferencesRootEvent.SwitchToSession -> coroutineScope.launch {
sessionStore.setLatestSession(event.sessionId.value)
}
PreferencesRootEvent.ToggleOtherAccountsExpanded -> coroutineScope.launch {
appPreferencesStore.setOtherAccountsExpanded(!isOtherAccountsSectionExpanded)
}
@@ -66,6 +66,7 @@ import io.element.android.libraries.designsystem.utils.snackbar.SnackbarHost
import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState
import io.element.android.libraries.emoji.api.picker.EmojiPickerRenderer
import io.element.android.libraries.emoji.api.picker.NoOpEmojiPickerRenderer
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.components.MatrixUserRow
import io.element.android.libraries.matrix.ui.model.getAvatarData
@@ -80,6 +81,7 @@ fun PreferencesRootView(
emojiPickerRenderer: EmojiPickerRenderer,
onBackClick: () -> Unit,
onAddAccountClick: () -> Unit,
onSwitchToSession: (SessionId) -> Unit,
onOpenAnalytics: () -> Unit,
onOpenLockScreenSettings: () -> Unit,
onOpenAbout: () -> Unit,
@@ -125,6 +127,7 @@ fun PreferencesRootView(
MultiAccountSection(
state = state,
onAddAccountClick = onAddAccountClick,
onSwitchToSession = onSwitchToSession,
)
}
HorizontalDivider(
@@ -209,6 +212,7 @@ private fun ColumnScope.UserStatusSection(
private fun ColumnScope.MultiAccountSection(
state: PreferencesRootState,
onAddAccountClick: () -> Unit,
onSwitchToSession: (SessionId) -> Unit,
) {
HorizontalDivider(
thickness = 8.dp,
@@ -276,7 +280,7 @@ private fun ColumnScope.MultiAccountSection(
MatrixUserRow(
modifier = Modifier
.clickable {
state.eventSink(PreferencesRootEvent.SwitchToSession(otherAccount.user.userId))
onSwitchToSession(otherAccount.user.userId)
}
.padding(top = 2.dp, bottom = 2.dp, end = 8.dp),
matrixUser = otherAccount.user,
@@ -433,6 +437,7 @@ private fun ContentToPreview(state: PreferencesRootState) {
emojiPickerRenderer = NoOpEmojiPickerRenderer,
onBackClick = {},
onAddAccountClick = {},
onSwitchToSession = {},
onOpenAnalytics = {},
onOpenDeveloperSettings = {},
onOpenLocationSettings = {},
@@ -19,6 +19,7 @@ import io.element.android.features.logout.test.FakeLogoutEntryPoint
import io.element.android.features.preferences.api.PreferencesEntryPoint
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.mediaviewer.test.FakeFileViewerEntryPoint
import io.element.android.libraries.troubleshoot.test.FakeNotificationTroubleShootEntryPoint
import io.element.android.libraries.troubleshoot.test.FakePushHistoryEntryPoint
@@ -52,6 +53,7 @@ class DefaultPreferencesEntryPointTest {
}
val callback = object : PreferencesEntryPoint.Callback {
override fun navigateToAddAccount() = lambdaError()
override fun navigateToSession(sessionId: SessionId) = lambdaError()
override fun navigateToLinkNewDevice() = lambdaError()
override fun navigateToBugReport() = lambdaError()
override fun navigateToSecureBackup() = lambdaError()
@@ -40,6 +40,7 @@ import io.element.android.tests.testutils.consumeItemsUntilPredicate
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.test
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.TestScope
@@ -1176,6 +1177,7 @@ class NotificationSettingsPresenterTest {
stringProvider = FakeStringProvider(),
sessionCoroutineScope = backgroundScope,
featureFlagService = featureFlagService,
dispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true),
)
}
}
@@ -44,8 +44,6 @@ import io.element.android.libraries.sessionstorage.test.aSessionData
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.consumeItemsUntilPredicate
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.test
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
@@ -184,24 +182,6 @@ class PreferencesRootPresenterTest {
}
}
@Test
fun `present - switch session invoke method on the session store`() = runTest {
val setLatestSessionResult = lambdaRecorder<String, Unit> { }
val sessionStore = InMemorySessionStore(
initialList = listOf(
aSessionData(sessionId = A_SESSION_ID.value),
aSessionData(sessionId = A_SESSION_ID_2.value),
),
setLatestSessionResult = setLatestSessionResult,
)
createPreferencesRootPresenter(sessionStore = sessionStore).test {
val loadedState = awaitFirstItem()
loadedState.eventSink(PreferencesRootEvent.SwitchToSession(A_SESSION_ID_2))
setLatestSessionResult.assertions().isCalledOnce()
.with(value(A_SESSION_ID_2.value))
}
}
@Test
fun `present - labs can be shown if any feature flag is in labs and not finished`() = runTest {
createPreferencesRootPresenter(
@@ -20,6 +20,7 @@ import androidx.compose.ui.test.performScrollTo
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import io.element.android.features.preferences.impl.R
import io.element.android.libraries.emoji.api.picker.NoOpEmojiPickerRenderer
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.test.A_USER_ID_2
import io.element.android.libraries.matrix.ui.components.aMatrixUser
@@ -69,25 +70,27 @@ class PreferencesRootViewTest : RobolectricTest() {
}
@Test
fun `clicking on other session sends a SwitchToSession`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<PreferencesRootEvent>()
setView(
aPreferencesRootState(
isMultiAccountEnabled = true,
isOtherAccountsSectionExpanded = true,
otherSessions = listOf(
anOtherAccount(
user = aMatrixUser(
id = A_USER_ID_2.value,
displayName = "Bob",
),
)
fun `clicking on other session invokes the expected callback`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false)
ensureCalledOnceWithParam(A_USER_ID_2) { callback ->
setView(
aPreferencesRootState(
isMultiAccountEnabled = true,
isOtherAccountsSectionExpanded = true,
otherSessions = listOf(
anOtherAccount(
user = aMatrixUser(
id = A_USER_ID_2.value,
displayName = "Bob",
),
)
),
eventSink = eventsRecorder,
),
eventSink = eventsRecorder,
),
)
onNodeWithText("Bob").performClick()
eventsRecorder.assertSingle(PreferencesRootEvent.SwitchToSession(A_USER_ID_2))
onSwitchToSession = callback,
)
onNodeWithText("Bob").performClick()
}
}
@Test
@@ -425,6 +428,7 @@ private fun AndroidComposeUiTest<ComponentActivity>.setView(
state: PreferencesRootState,
onBackClick: () -> Unit = EnsureNeverCalled(),
onAddAccountClick: () -> Unit = EnsureNeverCalled(),
onSwitchToSession: (SessionId) -> Unit = EnsureNeverCalledWithParam(),
onOpenAnalytics: () -> Unit = EnsureNeverCalled(),
onOpenLockScreenSettings: () -> Unit = EnsureNeverCalled(),
onOpenAbout: () -> Unit = EnsureNeverCalled(),
@@ -448,6 +452,7 @@ private fun AndroidComposeUiTest<ComponentActivity>.setView(
emojiPickerRenderer = NoOpEmojiPickerRenderer,
onBackClick = onBackClick,
onAddAccountClick = onAddAccountClick,
onSwitchToSession = onSwitchToSession,
onOpenAnalytics = onOpenAnalytics,
onOpenLockScreenSettings = onOpenLockScreenSettings,
onOpenAbout = onOpenAbout,