diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomePresenter.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomePresenter.kt index c250569976..e0c7829b0f 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomePresenter.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomePresenter.kt @@ -14,7 +14,6 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable @@ -34,17 +33,13 @@ import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.indicator.api.IndicatorService import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.sync.SyncService +import io.element.android.libraries.matrix.api.totalUnreadCountFlow import io.element.android.libraries.sessionstorage.api.SessionStore import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.filter -import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.launch -import kotlin.time.Duration.Companion.milliseconds @Inject class HomePresenter( @@ -63,20 +58,13 @@ class HomePresenter( ) : Presenter { private val currentUserWithNeighborsBuilder = CurrentUserWithNeighborsBuilder() - @OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class) @Composable override fun present(): HomeState { val coroutineScope = rememberCoroutineScope() val matrixUser by client.userProfile.collectAsState() - - val totalUnreadCount by produceState(client.totalUnreadCount().getOrDefault(0).toInt()) { - syncService.heartBeat - // Delay the heartbeat so the SDK has time to update the unread count before we read it. - .debounce(100.milliseconds) - .mapLatest { client.totalUnreadCount().getOrDefault(0).toInt() } - .collect { value = it } - } - + val totalUnreadCount by remember { client.totalUnreadCountFlow() } + // The initial value is only used for the first composition, remember it to avoid calling the SDK on every recomposition. + .collectAsState(initial = remember { client.totalUnreadCount().getOrDefault(0).toInt() }) val currentUserAndNeighbors by remember { combine( client.userProfile, diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/HomePresenterTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/HomePresenterTest.kt index 8bd779ef05..93ea9576b8 100644 --- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/HomePresenterTest.kt +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/HomePresenterTest.kt @@ -6,6 +6,8 @@ * Please see LICENSE files in the repository root for full details. */ +@file:OptIn(ExperimentalCoroutinesApi::class) + package io.element.android.features.home.impl import com.google.common.truth.Truth.assertThat @@ -129,7 +131,10 @@ class HomePresenterTest { var unreadCount = 0L val syncService = FakeSyncService() val presenter = createHomePresenter( - client = FakeMatrixClient(totalUnreadCountResult = { Result.success(unreadCount) }), + client = FakeMatrixClient( + syncService = syncService, + totalUnreadCountResult = { Result.success(unreadCount) }, + ), syncService = syncService, ) presenter.test { @@ -146,7 +151,10 @@ class HomePresenterTest { fun `present - total unread count defaults to 0 when the SDK call fails`() = runTest { val syncService = FakeSyncService() val presenter = createHomePresenter( - client = FakeMatrixClient(totalUnreadCountResult = { Result.failure(AN_EXCEPTION) }), + client = FakeMatrixClient( + syncService = syncService, + totalUnreadCountResult = { Result.failure(AN_EXCEPTION) }, + ), syncService = syncService, ) presenter.test { diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/OtherAccount.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/OtherAccount.kt new file mode 100644 index 0000000000..7ac47056c2 --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/OtherAccount.kt @@ -0,0 +1,21 @@ +/* + * 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.preferences.impl.root + +import io.element.android.libraries.matrix.api.user.MatrixUser + +/** + * A signed-in account which is not the current one. + * + * @param user the user of the account. + * @param unreadCount the total number of unread notifications for this account. + */ +data class OtherAccount( + val user: MatrixUser, + val unreadCount: Int, +) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenter.kt index 7d12fd8f51..f3223b3d37 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenter.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenter.kt @@ -30,6 +30,9 @@ import io.element.android.libraries.di.annotations.SessionCoroutineScope import io.element.android.libraries.featureflag.api.FeatureFlagService import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.MatrixClientProvider +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.api.totalUnreadCountFlow import io.element.android.libraries.matrix.api.user.toMatrixUser import io.element.android.libraries.preferences.api.store.AppPreferencesStore import io.element.android.libraries.sessionstorage.api.SessionStore @@ -37,9 +40,15 @@ import io.element.android.services.analytics.api.AnalyticsService import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch +@OptIn(ExperimentalCoroutinesApi::class) @Inject class PreferencesRootPresenter( private val matrixClient: MatrixClient, @@ -50,6 +59,7 @@ class PreferencesRootPresenter( private val featureFlagService: FeatureFlagService, private val locationService: LocationService, private val sessionStore: SessionStore, + private val matrixClientProvider: MatrixClientProvider, private val appPreferencesStore: AppPreferencesStore, private val userStatusPresenter: Presenter, private val preferencesAccountPresenter: Presenter, @@ -83,12 +93,20 @@ class PreferencesRootPresenter( }.collectAsState(initial = Theme.System) val otherSessions by remember { - sessionStore.sessionsFlow().map { list -> - list - .filter { it.userId != matrixClient.sessionId.value } - .map { it.toMatrixUser() } - .toImmutableList() - } + sessionStore.sessionsFlow() + .map { list -> list.filter { it.userId != matrixClient.sessionId.value } } + .flatMapLatest { sessions -> + if (sessions.isEmpty()) { + flowOf(persistentListOf()) + } else { + combine( + sessions.map { sessionData -> + val user = sessionData.toMatrixUser() + unreadCountFlow(user.userId).map { unreadCount -> OtherAccount(user, unreadCount) } + } + ) { it.toImmutableList() } + } + } }.collectAsState(initial = persistentListOf()) val themeOption by remember { @@ -156,4 +174,9 @@ class PreferencesRootPresenter( eventSink = ::handleEvent, ) } + + private fun unreadCountFlow(sessionId: SessionId): Flow { + val client = matrixClientProvider.getOrNull(sessionId) ?: return flowOf(0) + return client.totalUnreadCountFlow() + } } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootState.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootState.kt index b51d83592f..bf9fb14416 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootState.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootState.kt @@ -28,7 +28,7 @@ data class PreferencesRootState( val version: String, val isMultiAccountEnabled: Boolean, val isOtherAccountsSectionExpanded: Boolean, - val otherSessions: ImmutableList, + val otherSessions: ImmutableList, val showAnalyticsSettings: Boolean, val showDeveloperSettings: Boolean, val showLabsItem: Boolean, diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootStatePreviewParam.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootStatePreviewParam.kt index 1bbd9e0548..3125291c56 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootStatePreviewParam.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootStatePreviewParam.kt @@ -29,7 +29,7 @@ open class PreferencesRootStatePreviewParam : PreviewParameterProvider + anOtherAccount(user = user, unreadCount = unreadCount) + }, + ), ) } @@ -66,7 +74,7 @@ fun aPreferencesRootState( isOtherAccountsSectionExpanded: Boolean = false, theme: ThemeOption = ThemeOption.System, availableThemeOptions: List = ThemeOption.entries, - otherSessions: List = emptyList(), + otherSessions: List = emptyList(), showAnalyticsSettings: Boolean = false, showDeveloperSettings: Boolean = false, showLabsItem: Boolean = false, @@ -91,3 +99,11 @@ fun aPreferencesRootState( snackbarMessage = snackbarMessage, eventSink = eventSink, ) + +fun anOtherAccount( + user: MatrixUser = aMatrixUser(), + unreadCount: Int = 0, +) = OtherAccount( + user = user, + unreadCount = unreadCount, +) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt index a509dbfbd0..79aabeea2e 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt @@ -43,6 +43,7 @@ import io.element.android.features.preferences.impl.userstatus.UserStatusState import io.element.android.features.preferences.impl.userstatus.UserStatusView import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.coverage.ExcludeFromCoverage +import io.element.android.libraries.designsystem.atomic.atoms.CounterAtom import io.element.android.libraries.designsystem.components.async.AsyncActionIndicator import io.element.android.libraries.designsystem.components.async.AsyncIndicator import io.element.android.libraries.designsystem.components.avatar.AvatarRow @@ -244,7 +245,7 @@ private fun ColumnScope.MultiAccountSection( AvatarRow( avatarDataList = state.otherSessions .take(3) - .map { it.getAvatarData(AvatarSize.OtherAccountItem) } + .map { it.user.getAvatarData(AvatarSize.OtherAccountItem) } .toImmutableList(), avatarType = AvatarType.User, lastOnTop = true, @@ -275,16 +276,21 @@ private fun ColumnScope.MultiAccountSection( thickness = 1.dp, color = ElementTheme.colors.bgSubtleSecondary, ) - state.otherSessions.forEach { matrixUser -> + state.otherSessions.forEach { otherAccount -> MatrixUserRow( modifier = Modifier .clickable { - onSwitchToSession(matrixUser.userId) + onSwitchToSession(otherAccount.user.userId) } .padding(top = 2.dp, bottom = 2.dp, end = 8.dp), - matrixUser = matrixUser, + matrixUser = otherAccount.user, avatarSize = AvatarSize.AccountItem, verticalSpaceWidth = 16.dp, + trailingContent = if (otherAccount.unreadCount > 0) { + { CounterAtom(count = otherAccount.unreadCount) } + } else { + null + }, ) } AddAccountItem(onAddAccountClick) diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenterTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenterTest.kt index 45a0f577eb..57b85af096 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenterTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenterTest.kt @@ -24,13 +24,18 @@ import io.element.android.libraries.featureflag.api.FeatureFlagService import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.featureflag.test.FakeFeature import io.element.android.libraries.featureflag.test.FakeFeatureFlagService +import io.element.android.libraries.matrix.api.MatrixClientProvider import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.test.AN_AVATAR_URL +import io.element.android.libraries.matrix.test.AN_EXCEPTION import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.A_SESSION_ID_2 +import io.element.android.libraries.matrix.test.A_USER_ID_3 import io.element.android.libraries.matrix.test.A_USER_NAME import io.element.android.libraries.matrix.test.FakeMatrixClient +import io.element.android.libraries.matrix.test.FakeMatrixClientProvider import io.element.android.libraries.matrix.test.core.aBuildMeta +import io.element.android.libraries.matrix.test.sync.FakeSyncService import io.element.android.libraries.preferences.api.store.AppPreferencesStore import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore import io.element.android.libraries.sessionstorage.api.SessionStore @@ -236,16 +241,62 @@ class PreferencesRootPresenterTest { assertThat(state.isMultiAccountEnabled).isTrue() assertThat(state.otherSessions).hasSize(1) assertThat(state.otherSessions[0]).isEqualTo( - MatrixUser( - userId = A_SESSION_ID_2, - displayName = "Bob", - avatarUrl = "avatarUrl", - avatarThumbnail = "avatarData", + OtherAccount( + user = MatrixUser( + userId = A_SESSION_ID_2, + displayName = "Bob", + avatarUrl = "avatarUrl", + avatarThumbnail = "avatarData", + ), + unreadCount = 0, ) ) } } + @Test + fun `present - other accounts keep the session store order and get their unread count`() = runTest { + var bobUnreadCount = 3L + val bobSyncService = FakeSyncService() + val bobClient = FakeMatrixClient( + sessionId = A_SESSION_ID_2, + syncService = bobSyncService, + totalUnreadCountResult = { Result.success(bobUnreadCount) }, + ) + createPreferencesRootPresenter( + matrixClient = FakeMatrixClient(sessionId = A_SESSION_ID), + sessionStore = InMemorySessionStore( + initialList = listOf( + aSessionData(sessionId = A_USER_ID_3.value), + aSessionData(sessionId = A_SESSION_ID.value), + aSessionData(sessionId = A_SESSION_ID_2.value), + ) + ), + matrixClientProvider = FakeMatrixClientProvider { sessionId -> + when (sessionId) { + A_SESSION_ID_2 -> Result.success(bobClient) + // Client for the third account is not in memory + else -> Result.failure(AN_EXCEPTION) + } + }, + ).test { + val state = consumeItemsUntilPredicate { it.otherSessions.isNotEmpty() }.last() + assertThat(state.otherSessions.map { it.user.userId to it.unreadCount }).containsExactly( + A_USER_ID_3 to 0, + A_SESSION_ID_2 to 3, + ).inOrder() + // The unread count is refreshed on sync heartbeat + bobUnreadCount = 5 + bobSyncService.emitHeartBeat() + val updatedState = consumeItemsUntilPredicate { state -> state.otherSessions.any { it.unreadCount == 5 } }.last() + assertThat(updatedState.otherSessions.map { it.user.userId to it.unreadCount }).containsExactly( + A_USER_ID_3 to 0, + A_SESSION_ID_2 to 5, + ).inOrder() + cancelAndIgnoreRemainingEvents() + } + } + @Test fun `present - other accounts section is collapsed if the preference says so`() = runTest { createPreferencesRootPresenter( @@ -285,6 +336,7 @@ class PreferencesRootPresenterTest { sessionStore: SessionStore = InMemorySessionStore(), appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(), locationService: LocationService = FakeLocationService(isServiceAvailable = true), + matrixClientProvider: MatrixClientProvider = FakeMatrixClientProvider(), ) = PreferencesRootPresenter( matrixClient = matrixClient, analyticsService = FakeAnalyticsService(), @@ -294,6 +346,7 @@ class PreferencesRootPresenterTest { featureFlagService = featureFlagService, locationService = locationService, sessionStore = sessionStore, + matrixClientProvider = matrixClientProvider, appPreferencesStore = appPreferencesStore, userStatusPresenter = { aUserStatusState() }, preferencesAccountPresenter = { aPreferencesAccountState() }, diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootViewTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootViewTest.kt index 6b5ba45109..224068a566 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootViewTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootViewTest.kt @@ -78,9 +78,11 @@ class PreferencesRootViewTest : RobolectricTest() { isMultiAccountEnabled = true, isOtherAccountsSectionExpanded = true, otherSessions = listOf( - aMatrixUser( - id = A_USER_ID_2.value, - displayName = "Bob", + anOtherAccount( + user = aMatrixUser( + id = A_USER_ID_2.value, + displayName = "Bob", + ), ) ), eventSink = eventsRecorder, @@ -91,6 +93,28 @@ class PreferencesRootViewTest : RobolectricTest() { } } + @Test + fun `unread count of other session is displayed`() = runAndroidComposeUiTest { + val eventsRecorder = EventsRecorder(expectEvents = false) + setView( + aPreferencesRootState( + isMultiAccountEnabled = true, + isOtherAccountsSectionExpanded = true, + otherSessions = listOf( + anOtherAccount( + user = aMatrixUser( + id = A_USER_ID_2.value, + displayName = "Bob", + ), + unreadCount = 42, + ) + ), + eventSink = eventsRecorder, + ), + ) + onNodeWithText("42").assertExists() + } + @Test fun `clicking on Switch accounts sends a ToggleOtherAccountsExpanded`() = runAndroidComposeUiTest { val eventsRecorder = EventsRecorder() @@ -99,9 +123,11 @@ class PreferencesRootViewTest : RobolectricTest() { isMultiAccountEnabled = true, isOtherAccountsSectionExpanded = false, otherSessions = listOf( - aMatrixUser( - id = A_USER_ID_2.value, - displayName = "Bob", + anOtherAccount( + user = aMatrixUser( + id = A_USER_ID_2.value, + displayName = "Bob", + ), ) ), eventSink = eventsRecorder, @@ -119,9 +145,11 @@ class PreferencesRootViewTest : RobolectricTest() { isMultiAccountEnabled = true, isOtherAccountsSectionExpanded = false, otherSessions = listOf( - aMatrixUser( - id = A_USER_ID_2.value, - displayName = "Bob", + anOtherAccount( + user = aMatrixUser( + id = A_USER_ID_2.value, + displayName = "Bob", + ), ) ), eventSink = eventsRecorder, @@ -141,9 +169,11 @@ class PreferencesRootViewTest : RobolectricTest() { isMultiAccountEnabled = true, isOtherAccountsSectionExpanded = true, otherSessions = listOf( - aMatrixUser( - id = A_USER_ID_2.value, - displayName = "Bob", + anOtherAccount( + user = aMatrixUser( + id = A_USER_ID_2.value, + displayName = "Bob", + ), ) ), eventSink = eventsRecorder, diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt index 51579a88d4..1260c9ec82 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt @@ -51,9 +51,15 @@ import io.element.android.libraries.matrix.api.user.UserStatus import io.element.android.libraries.matrix.api.verification.SessionVerificationService import kotlinx.collections.immutable.ImmutableList import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart import java.util.Optional +import kotlin.time.Duration.Companion.milliseconds /** * Entry point for everything scoped to a single logged in session: the sub-services, room access, and account level operations. @@ -523,3 +529,18 @@ fun MatrixClient.roomAliasFromName(name: String): RoomAlias? { ?.takeIf { MatrixPatterns.isRoomAlias(it) } ?.let { tryOrNull { RoomAlias(it) } } } + +/** + * Returns a flow of the total number of unread notifications across all rooms. + * The first value is emitted immediately, then the value is refreshed on each sync heartbeat. + * In case of error, 0 is emitted. + */ +@OptIn(FlowPreview::class) +fun MatrixClient.totalUnreadCountFlow(): Flow { + return syncService.heartBeat + // Delay the heartbeat so the SDK has time to update the unread count before we read it. + .debounce(100.milliseconds) + .onStart { emit(Unit) } + .map { totalUnreadCount().getOrDefault(0).toInt() } + .distinctUntilChanged() +} diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewDark_8_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewDark_8_en.png new file mode 100644 index 0000000000..dfac3304ff --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewDark_8_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b139c8d8394f645ef9be7fec9ec485511e78819289ef82d80e0db20cf539e9df +size 42982 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewLight_8_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewLight_8_en.png new file mode 100644 index 0000000000..4d47b0a99e --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewLight_8_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cf09bfe58fb29716b1ce22d42e38f287684c5ba0b24d12c7bba7413259dc52de +size 44470