Browse Source

Merge branch 'develop' into feature/fga/better_timeline_scroll

pull/868/head
ganfra 1 year ago
parent
commit
f4ee95635d
  1. 3
      .github/workflows/nightlyReports.yml
  2. 3
      .github/workflows/tests.yml
  3. 35
      .github/workflows/triage-labelled.yml
  4. 8
      app/src/main/kotlin/io/element/android/x/MainActivity.kt
  5. 2
      app/src/main/kotlin/io/element/android/x/di/AppBindings.kt
  6. 3
      app/src/main/kotlin/io/element/android/x/di/AppModule.kt
  7. 2
      appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt
  8. 12
      appnav/src/main/kotlin/io/element/android/appnav/NotLoggedInFlowNode.kt
  9. 7
      appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt
  10. 33
      appnav/src/main/kotlin/io/element/android/appnav/di/MatrixClientsHolder.kt
  11. 1
      features/messages/impl/src/test/kotlin/io/element/android/features/messages/forward/ForwardMessagesPresenterTests.kt
  12. 45
      features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/blockuser/BlockUserSection.kt
  13. 1
      features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsEvents.kt
  14. 38
      features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsPresenter.kt
  15. 4
      features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsState.kt
  16. 6
      features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsStateProvider.kt
  17. 31
      features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/members/details/RoomMemberDetailsPresenterTests.kt
  18. 14
      libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/BackstackNode.kt
  19. 30
      libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/LifecycleExt.kt
  20. 1
      libraries/core/src/main/kotlin/io/element/android/libraries/core/coroutine/CoroutineDispatchers.kt
  21. 2
      libraries/core/src/main/kotlin/io/element/android/libraries/core/extensions/BasicExtensions.kt
  22. 17
      libraries/deeplink/src/main/kotlin/io/element/android/libraries/deeplink/DeeplinkParser.kt
  23. 2
      libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/RoomMembershipContentFormatter.kt
  24. 2
      libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt
  25. 3
      libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomSummaryDataSource.kt
  26. 31
      libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt
  27. 11
      libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/RustMediaLoader.kt
  28. 2
      libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RoomListExtensions.kt
  29. 65
      libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt
  30. 6
      libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustRoomSummaryDataSource.kt
  31. 10
      libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustMatrixTimeline.kt
  32. 5
      libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt
  33. 2
      libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt
  34. 2
      libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/di/MatrixUIBindings.kt
  35. 2
      libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationFactory.kt
  36. 2
      libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/PendingIntentFactory.kt
  37. 3
      samples/minimal/src/main/kotlin/io/element/android/samples/minimal/Singleton.kt
  38. 2
      tests/testutils/src/main/kotlin/io/element/android/tests/testutils/TestCoroutineDispatchers.kt
  39. BIN
      tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members.details_null_DefaultGroup_RoomMemberDetailsViewDarkPreview--3_3_null_5,NEXUS_5,1.0,en].png
  40. BIN
      tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members.details_null_DefaultGroup_RoomMemberDetailsViewLightPreview--2_2_null_5,NEXUS_5,1.0,en].png

3
.github/workflows/nightlyReports.yml

@ -26,6 +26,9 @@ jobs: @@ -26,6 +26,9 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '17'
- name: Run unit & screenshot tests, debug and release
run: ./gradlew test $CI_GRADLE_ARG_PROPERTIES -Pci-build=true
- name: Run unit & screenshot tests, generate kover report
run: ./gradlew koverMergedReport $CI_GRADLE_ARG_PROPERTIES -Pci-build=true

3
.github/workflows/tests.yml

@ -37,6 +37,9 @@ jobs: @@ -37,6 +37,9 @@ jobs:
with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Run unit & screenshot tests, debug and release
run: ./gradlew test $CI_GRADLE_ARG_PROPERTIES -Pci-build=true
- name: Run unit & screenshot tests, generate kover report
run: ./gradlew koverMergedReport $CI_GRADLE_ARG_PROPERTIES -Pci-build=true

35
.github/workflows/triage-labelled.yml

@ -17,23 +17,24 @@ jobs: @@ -17,23 +17,24 @@ jobs:
project-url: https://github.com/orgs/vector-im/projects/43
github-token: ${{ secrets.ELEMENT_BOT_TOKEN }}
# move_needs_info:
# name: Move triaged needs info issues on board
# runs-on: ubuntu-latest
# steps:
# - uses: actions/add-to-project@main
# id: addItem
# with:
# project-url: https://github.com/orgs/vector-im/projects/91
# github-token: ${{ secrets.ELEMENT_BOT_TOKEN }}
# labeled: X-Needs-Info
#
# - uses: kalgurn/update-project-item-status@main
# with:
# project-url: https://github.com/orgs/vector-im/projects/91
# github-token: ${{ secrets.ELEMENT_BOT_TOKEN }}
# item-id: ${{ steps.addItem.outputs.itemId }}
# status: "Needs info"
move_needs_info:
name: Move triaged needs info issues on board
runs-on: ubuntu-latest
steps:
- uses: actions/add-to-project@main
id: addItem
with:
project-url: https://github.com/orgs/vector-im/projects/91
github-token: ${{ secrets.ELEMENT_BOT_TOKEN }}
labeled: X-Needs-Info
- name: Print itemId
run: echo ${{ steps.addItem.outputs.itemId }}
- uses: kalgurn/update-project-item-status@main
with:
project-url: https://github.com/orgs/vector-im/projects/91
github-token: ${{ secrets.ELEMENT_BOT_TOKEN }}
item-id: ${{ steps.addItem.outputs.itemId }}
status: "Needs info"
ex_plorers:
name: Add labelled issues to X-Plorer project

8
app/src/main/kotlin/io/element/android/x/MainActivity.kt

@ -52,8 +52,7 @@ class MainActivity : NodeComponentActivity() { @@ -52,8 +52,7 @@ class MainActivity : NodeComponentActivity() {
Timber.tag(loggerTag.value).w("onCreate, with savedInstanceState: ${savedInstanceState != null}")
installSplashScreen()
super.onCreate(savedInstanceState)
appBindings = bindings<AppBindings>()
appBindings.matrixClientsHolder().restore(savedInstanceState)
appBindings = bindings()
WindowCompat.setDecorFitsSystemWindows(window, false)
setContent {
MainContent(appBindings)
@ -125,9 +124,4 @@ class MainActivity : NodeComponentActivity() { @@ -125,9 +124,4 @@ class MainActivity : NodeComponentActivity() {
super.onDestroy()
Timber.tag(loggerTag.value).w("onDestroy")
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
bindings<AppBindings>().matrixClientsHolder().onSaveInstanceState(outState)
}
}

2
app/src/main/kotlin/io/element/android/x/di/AppBindings.kt

@ -17,13 +17,11 @@ @@ -17,13 +17,11 @@
package io.element.android.x.di
import com.squareup.anvil.annotations.ContributesTo
import io.element.android.appnav.di.MatrixClientsHolder
import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
import io.element.android.libraries.di.AppScope
@ContributesTo(AppScope::class)
interface AppBindings {
fun matrixClientsHolder(): MatrixClientsHolder
fun mainDaggerComponentOwner(): MainDaggerComponentsOwner
fun snackbarDispatcher(): SnackbarDispatcher
}

3
app/src/main/kotlin/io/element/android/x/di/AppModule.kt

@ -37,10 +37,8 @@ import kotlinx.coroutines.CoroutineName @@ -37,10 +37,8 @@ import kotlinx.coroutines.CoroutineName
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.plus
import java.io.File
import java.util.concurrent.Executors
@Module
@ContributesTo(AppScope::class)
@ -99,7 +97,6 @@ object AppModule { @@ -99,7 +97,6 @@ object AppModule {
io = Dispatchers.IO,
computation = Dispatchers.Default,
main = Dispatchers.Main,
diffUpdateDispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher()
)
}

2
appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt

@ -154,8 +154,6 @@ class LoggedInFlowNode @AssistedInject constructor( @@ -154,8 +154,6 @@ class LoggedInFlowNode @AssistedInject constructor(
syncService.stopSync()
},
onDestroy = {
val imageLoaderFactory = bindings<MatrixUIBindings>().notLoggedInImageLoaderFactory()
Coil.setImageLoader(imageLoaderFactory)
plugins<LifecycleCallback>().forEach { it.onFlowReleased(id, inputs.matrixClient) }
appNavigationStateService.onLeavingSpace(id)
appNavigationStateService.onLeavingSession(id)

12
appnav/src/main/kotlin/io/element/android/appnav/NotLoggedInFlowNode.kt

@ -19,6 +19,7 @@ package io.element.android.appnav @@ -19,6 +19,7 @@ package io.element.android.appnav
import android.os.Parcelable
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import coil.Coil
import com.bumble.appyx.core.composable.Children
import com.bumble.appyx.core.lifecycle.subscribe
import com.bumble.appyx.core.modality.BuildContext
@ -34,8 +35,8 @@ import io.element.android.features.onboarding.api.OnBoardingEntryPoint @@ -34,8 +35,8 @@ import io.element.android.features.onboarding.api.OnBoardingEntryPoint
import io.element.android.libraries.architecture.BackstackNode
import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.matrix.ui.media.NotLoggedInImageLoaderFactory
import kotlinx.parcelize.Parcelize
import timber.log.Timber
@ContributesNode(AppScope::class)
class NotLoggedInFlowNode @AssistedInject constructor(
@ -43,6 +44,7 @@ class NotLoggedInFlowNode @AssistedInject constructor( @@ -43,6 +44,7 @@ class NotLoggedInFlowNode @AssistedInject constructor(
@Assisted plugins: List<Plugin>,
private val onBoardingEntryPoint: OnBoardingEntryPoint,
private val loginEntryPoint: LoginEntryPoint,
private val notLoggedInImageLoaderFactory: NotLoggedInImageLoaderFactory,
) : BackstackNode<NotLoggedInFlowNode.NavTarget>(
backstack = BackStack(
initialElement = NavTarget.OnBoarding,
@ -51,10 +53,12 @@ class NotLoggedInFlowNode @AssistedInject constructor( @@ -51,10 +53,12 @@ class NotLoggedInFlowNode @AssistedInject constructor(
buildContext = buildContext,
plugins = plugins,
) {
init {
override fun onBuilt() {
super.onBuilt()
lifecycle.subscribe(
onCreate = { Timber.v("OnCreate") },
onDestroy = { Timber.v("OnDestroy") }
onCreate = {
Coil.setImageLoader(notLoggedInImageLoaderFactory)
},
)
}

7
appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt

@ -30,6 +30,7 @@ import com.bumble.appyx.core.node.Node @@ -30,6 +30,7 @@ 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.plugins
import com.bumble.appyx.core.state.MutableSavedStateMap
import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.pop
import com.bumble.appyx.navmodel.backstack.operation.push
@ -90,10 +91,16 @@ class RootFlowNode @AssistedInject constructor( @@ -90,10 +91,16 @@ class RootFlowNode @AssistedInject constructor(
) {
override fun onBuilt() {
matrixClientsHolder.restore(buildContext.savedStateMap)
super.onBuilt()
observeLoggedInState()
}
override fun onSaveInstanceState(state: MutableSavedStateMap) {
super.onSaveInstanceState(state)
matrixClientsHolder.save(state)
}
private fun observeLoggedInState() {
combine(
cacheService.onClearedCacheEventFlow(),

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

@ -16,13 +16,11 @@ @@ -16,13 +16,11 @@
package io.element.android.appnav.di
import android.os.Bundle
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.SingleIn
import com.bumble.appyx.core.state.MutableSavedStateMap
import com.bumble.appyx.core.state.SavedStateMap
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.UserId
import kotlinx.coroutines.runBlocking
import timber.log.Timber
import java.util.concurrent.ConcurrentHashMap
@ -30,7 +28,6 @@ import javax.inject.Inject @@ -30,7 +28,6 @@ import javax.inject.Inject
private const val SAVE_INSTANCE_KEY = "io.element.android.x.di.MatrixClientsHolder.SaveInstanceKey"
@SingleIn(AppScope::class)
class MatrixClientsHolder @Inject constructor(private val authenticationService: MatrixAuthenticationService) {
private val sessionIdsToMatrixClient = ConcurrentHashMap<SessionId, MatrixClient>()
@ -55,16 +52,20 @@ class MatrixClientsHolder @Inject constructor(private val authenticationService: @@ -55,16 +52,20 @@ class MatrixClientsHolder @Inject constructor(private val authenticationService:
return sessionIdsToMatrixClient[sessionId]
}
@Suppress("DEPRECATION")
fun restore(savedInstanceState: Bundle?) {
if (savedInstanceState == null || sessionIdsToMatrixClient.isNotEmpty()) return
val userIds = savedInstanceState.getSerializable(SAVE_INSTANCE_KEY) as? Array<UserId>
if (userIds.isNullOrEmpty()) return
@Suppress("UNCHECKED_CAST")
fun restore(state: SavedStateMap?) {
Timber.d("Restore state")
if (state == null || sessionIdsToMatrixClient.isNotEmpty()) return Unit.also {
Timber.w("Restore with non-empty map")
}
val sessionIds = state[SAVE_INSTANCE_KEY] as? Array<SessionId>
Timber.d("Restore matrix session keys = ${sessionIds?.map { it.value }}")
if (sessionIds.isNullOrEmpty()) return
// Not ideal but should only happens in case of process recreation. This ensure we restore all the active sessions before restoring the node graphs.
runBlocking {
userIds.forEach { userId ->
Timber.v("Restore matrix session: $userId")
authenticationService.restoreSession(userId)
sessionIds.forEach { sessionId ->
Timber.d("Restore matrix session: $sessionId")
authenticationService.restoreSession(sessionId)
.onSuccess { matrixClient ->
add(matrixClient)
}
@ -75,9 +76,9 @@ class MatrixClientsHolder @Inject constructor(private val authenticationService: @@ -75,9 +76,9 @@ class MatrixClientsHolder @Inject constructor(private val authenticationService:
}
}
fun onSaveInstanceState(outState: Bundle) {
fun save(state: MutableSavedStateMap) {
val sessionKeys = sessionIdsToMatrixClient.keys.toTypedArray()
Timber.v("Save matrix session keys = $sessionKeys")
outState.putSerializable(SAVE_INSTANCE_KEY, sessionKeys)
Timber.d("Save matrix session keys = ${sessionKeys.map { it.value }}")
state[SAVE_INSTANCE_KEY] = sessionKeys
}
}

1
features/messages/impl/src/test/kotlin/io/element/android/features/messages/forward/ForwardMessagesPresenterTests.kt

@ -65,7 +65,6 @@ class ForwardMessagesPresenterTests { @@ -65,7 +65,6 @@ class ForwardMessagesPresenterTests {
}.test {
val initialState = awaitItem()
skipItems(1)
val summary = aRoomSummaryDetail()
initialState.eventSink(ForwardMessagesEvents.ToggleSearchActive)
assertThat(awaitItem().isSearchActive).isTrue()

45
features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/blockuser/BlockUserSection.kt

@ -25,29 +25,66 @@ import androidx.compose.ui.res.stringResource @@ -25,29 +25,66 @@ import androidx.compose.ui.res.stringResource
import io.element.android.features.roomdetails.impl.R
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsEvents
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsState
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
import io.element.android.libraries.designsystem.components.dialogs.RetryDialog
import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory
import io.element.android.libraries.designsystem.components.preferences.PreferenceText
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
internal fun BlockUserSection(state: RoomMemberDetailsState, modifier: Modifier = Modifier) {
PreferenceCategory(showDivider = false, modifier = modifier) {
if (state.isBlocked) {
when (state.isBlocked) {
is Async.Failure -> PreferenceBlockUser(isBlocked = state.isBlocked.prevData, isLoading = false, eventSink = state.eventSink)
is Async.Loading -> PreferenceBlockUser(isBlocked = state.isBlocked.prevData, isLoading = true, eventSink = state.eventSink)
is Async.Success -> PreferenceBlockUser(isBlocked = state.isBlocked.data, isLoading = false, eventSink = state.eventSink)
Async.Uninitialized -> PreferenceBlockUser(isBlocked = null, isLoading = true, eventSink = state.eventSink)
}
}
if (state.isBlocked is Async.Failure) {
RetryDialog(
content = stringResource(CommonStrings.error_unknown),
onDismiss = { state.eventSink(RoomMemberDetailsEvents.ClearBlockUserError) },
onRetry = {
val event = when (state.isBlocked.prevData) {
true -> RoomMemberDetailsEvents.UnblockUser(needsConfirmation = false)
false -> RoomMemberDetailsEvents.BlockUser(needsConfirmation = false)
null -> /*Should not happen */ RoomMemberDetailsEvents.ClearBlockUserError
}
state.eventSink(event)
},
)
}
}
@Composable
private fun PreferenceBlockUser(
isBlocked: Boolean?,
isLoading: Boolean,
eventSink: (RoomMemberDetailsEvents) -> Unit,
modifier: Modifier = Modifier,
) {
if (isBlocked.orFalse()) {
PreferenceText(
title = stringResource(R.string.screen_dm_details_unblock_user),
icon = Icons.Outlined.Block,
onClick = { state.eventSink(RoomMemberDetailsEvents.UnblockUser(needsConfirmation = true)) },
onClick = { if (!isLoading) eventSink(RoomMemberDetailsEvents.UnblockUser(needsConfirmation = true)) },
loadingCurrentValue = isLoading,
modifier = modifier,
)
} else {
PreferenceText(
title = stringResource(R.string.screen_dm_details_block_user),
icon = Icons.Outlined.Block,
tintColor = MaterialTheme.colorScheme.error,
onClick = { state.eventSink(RoomMemberDetailsEvents.BlockUser(needsConfirmation = true)) },
onClick = { if (!isLoading) eventSink(RoomMemberDetailsEvents.BlockUser(needsConfirmation = true)) },
loadingCurrentValue = isLoading,
modifier = modifier,
)
}
}
}
@Composable
internal fun BlockUserDialogs(state: RoomMemberDetailsState) {

1
features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsEvents.kt

@ -19,5 +19,6 @@ package io.element.android.features.roomdetails.impl.members.details @@ -19,5 +19,6 @@ package io.element.android.features.roomdetails.impl.members.details
sealed interface RoomMemberDetailsEvents {
data class BlockUser(val needsConfirmation: Boolean = false) : RoomMemberDetailsEvents
data class UnblockUser(val needsConfirmation: Boolean = false) : RoomMemberDetailsEvents
object ClearBlockUserError : RoomMemberDetailsEvents
object ClearConfirmationDialog : RoomMemberDetailsEvents
}

38
features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsPresenter.kt

@ -28,6 +28,7 @@ import androidx.compose.runtime.setValue @@ -28,6 +28,7 @@ import androidx.compose.runtime.setValue
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsState.ConfirmationDialog
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.matrix.api.MatrixClient
@ -53,8 +54,13 @@ class RoomMemberDetailsPresenter @AssistedInject constructor( @@ -53,8 +54,13 @@ class RoomMemberDetailsPresenter @AssistedInject constructor(
var confirmationDialog by remember { mutableStateOf<ConfirmationDialog?>(null) }
val roomMember by room.getRoomMemberAsState(roomMemberId)
// the room member is not really live...
val isBlocked = remember {
mutableStateOf(roomMember?.isIgnored.orFalse())
val isBlocked: MutableState<Async<Boolean>> = remember(roomMember) {
val isIgnored = roomMember?.isIgnored
if (isIgnored == null) {
mutableStateOf(Async.Uninitialized)
} else {
mutableStateOf(Async.Success(isIgnored))
}
}
LaunchedEffect(Unit) {
room.updateMembers()
@ -79,6 +85,9 @@ class RoomMemberDetailsPresenter @AssistedInject constructor( @@ -79,6 +85,9 @@ class RoomMemberDetailsPresenter @AssistedInject constructor(
}
}
RoomMemberDetailsEvents.ClearConfirmationDialog -> confirmationDialog = null
RoomMemberDetailsEvents.ClearBlockUserError -> {
isBlocked.value = Async.Success(isBlocked.value.dataOrNull().orFalse())
}
}
}
@ -105,20 +114,31 @@ class RoomMemberDetailsPresenter @AssistedInject constructor( @@ -105,20 +114,31 @@ class RoomMemberDetailsPresenter @AssistedInject constructor(
)
}
private fun CoroutineScope.blockUser(userId: UserId, isBlockedState: MutableState<Boolean>) = launch {
private fun CoroutineScope.blockUser(userId: UserId, isBlockedState: MutableState<Async<Boolean>>) = launch {
isBlockedState.value = Async.Loading(false)
client.ignoreUser(userId)
.map {
isBlockedState.value = true
.fold(
onSuccess = {
isBlockedState.value = Async.Success(true)
room.updateMembers()
},
onFailure = {
isBlockedState.value = Async.Failure(it, false)
}
)
}
private fun CoroutineScope.unblockUser(userId: UserId, isBlockedState: MutableState<Boolean>) = launch {
private fun CoroutineScope.unblockUser(userId: UserId, isBlockedState: MutableState<Async<Boolean>>) = launch {
isBlockedState.value = Async.Loading(true)
client.unignoreUser(userId)
.map {
isBlockedState.value = false
.fold(
onSuccess = {
isBlockedState.value = Async.Success(false)
room.updateMembers()
},
onFailure = {
isBlockedState.value = Async.Failure(it, true)
}
)
}
}

4
features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsState.kt

@ -16,11 +16,13 @@ @@ -16,11 +16,13 @@
package io.element.android.features.roomdetails.impl.members.details
import io.element.android.libraries.architecture.Async
data class RoomMemberDetailsState(
val userId: String,
val userName: String?,
val avatarUrl: String?,
val isBlocked: Boolean,
val isBlocked: Async<Boolean>,
val displayConfirmationDialog: ConfirmationDialog? = null,
val isCurrentUser: Boolean,
val eventSink: (RoomMemberDetailsEvents) -> Unit

6
features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsStateProvider.kt

@ -17,15 +17,17 @@ @@ -17,15 +17,17 @@
package io.element.android.features.roomdetails.impl.members.details
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.Async
open class RoomMemberDetailsStateProvider : PreviewParameterProvider<RoomMemberDetailsState> {
override val values: Sequence<RoomMemberDetailsState>
get() = sequenceOf(
aRoomMemberDetailsState(),
aRoomMemberDetailsState().copy(userName = null),
aRoomMemberDetailsState().copy(isBlocked = true),
aRoomMemberDetailsState().copy(isBlocked = Async.Success(true)),
aRoomMemberDetailsState().copy(displayConfirmationDialog = RoomMemberDetailsState.ConfirmationDialog.Block),
aRoomMemberDetailsState().copy(displayConfirmationDialog = RoomMemberDetailsState.ConfirmationDialog.Unblock),
aRoomMemberDetailsState().copy(isBlocked = Async.Loading(true)),
// Add other states here
)
}
@ -34,7 +36,7 @@ fun aRoomMemberDetailsState() = RoomMemberDetailsState( @@ -34,7 +36,7 @@ fun aRoomMemberDetailsState() = RoomMemberDetailsState(
userId = "@daniel:domain.com",
userName = "Daniel",
avatarUrl = null,
isBlocked = false,
isBlocked = Async.Success(false),
isCurrentUser = false,
eventSink = {},
)

31
features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/members/details/RoomMemberDetailsPresenterTests.kt

@ -25,7 +25,9 @@ import io.element.android.features.roomdetails.impl.members.aRoomMember @@ -25,7 +25,9 @@ import io.element.android.features.roomdetails.impl.members.aRoomMember
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsEvents
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsPresenter
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsState
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
import io.element.android.libraries.matrix.test.A_THROWABLE
import io.element.android.libraries.matrix.test.FakeMatrixClient
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
@ -50,7 +52,7 @@ class RoomMemberDetailsPresenterTests { @@ -50,7 +52,7 @@ class RoomMemberDetailsPresenterTests {
Truth.assertThat(initialState.userId).isEqualTo(roomMember.userId.value)
Truth.assertThat(initialState.userName).isEqualTo(roomMember.displayName)
Truth.assertThat(initialState.avatarUrl).isEqualTo(roomMember.avatarUrl)
Truth.assertThat(initialState.isBlocked).isEqualTo(roomMember.isIgnored)
Truth.assertThat(initialState.isBlocked).isEqualTo(Async.Success(roomMember.isIgnored))
skipItems(1)
val loadedState = awaitItem()
Truth.assertThat(loadedState.userName).isEqualTo("A custom name")
@ -129,10 +131,33 @@ class RoomMemberDetailsPresenterTests { @@ -129,10 +131,33 @@ class RoomMemberDetailsPresenterTests {
}.test {
val initialState = awaitItem()
initialState.eventSink(RoomMemberDetailsEvents.BlockUser(needsConfirmation = false))
Truth.assertThat(awaitItem().isBlocked).isTrue()
Truth.assertThat(awaitItem().isBlocked.isLoading()).isTrue()
Truth.assertThat(awaitItem().isBlocked.dataOrNull()).isTrue()
initialState.eventSink(RoomMemberDetailsEvents.UnblockUser(needsConfirmation = false))
Truth.assertThat(awaitItem().isBlocked).isFalse()
Truth.assertThat(awaitItem().isBlocked.isLoading()).isTrue()
Truth.assertThat(awaitItem().isBlocked.dataOrNull()).isFalse()
}
}
@Test
fun `present - BlockUser with error`() = runTest {
val room = aMatrixRoom()
val roomMember = aRoomMember()
val matrixClient = FakeMatrixClient()
matrixClient.givenIgnoreUserResult(Result.failure(A_THROWABLE))
val presenter = RoomMemberDetailsPresenter(matrixClient, room, roomMember.userId)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(RoomMemberDetailsEvents.BlockUser(needsConfirmation = false))
Truth.assertThat(awaitItem().isBlocked.isLoading()).isTrue()
val errorState = awaitItem()
Truth.assertThat(errorState.isBlocked.errorOrNull()).isEqualTo(A_THROWABLE)
// Clear error
initialState.eventSink(RoomMemberDetailsEvents.ClearBlockUserError)
Truth.assertThat(awaitItem().isBlocked).isEqualTo(Async.Success(false))
}
}

14
libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/BackstackNode.kt

@ -19,6 +19,7 @@ package io.element.android.libraries.architecture @@ -19,6 +19,7 @@ package io.element.android.libraries.architecture
import androidx.compose.runtime.Stable
import com.bumble.appyx.core.children.ChildEntry
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.node.ParentNode
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.navmodel.backstack.BackStack
@ -39,4 +40,15 @@ abstract class BackstackNode<NavTarget : Any>( @@ -39,4 +40,15 @@ abstract class BackstackNode<NavTarget : Any>(
buildContext = buildContext,
plugins = plugins,
childKeepMode = childKeepMode,
)
) {
override fun onBuilt() {
super.onBuilt()
lifecycle.logLifecycle(this::class.java.simpleName)
whenChildAttached<Node> { _, child ->
// BackstackNode will be logged by their parent.
if (child !is BackstackNode<*>) {
child.lifecycle.logLifecycle(child::class.java.simpleName)
}
}
}
}

30
libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/LifecycleExt.kt

@ -0,0 +1,30 @@ @@ -0,0 +1,30 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.architecture
import androidx.lifecycle.Lifecycle
import com.bumble.appyx.core.lifecycle.subscribe
import timber.log.Timber
fun Lifecycle.logLifecycle(name: String) {
subscribe(
onCreate = { Timber.tag("Lifecycle").d("onCreate $name") },
onPause = { Timber.tag("Lifecycle").d("onPause $name") },
onResume = { Timber.tag("Lifecycle").d("onResume $name") },
onDestroy = { Timber.tag("Lifecycle").d("onDestroy $name") },
)
}

1
libraries/core/src/main/kotlin/io/element/android/libraries/core/coroutine/CoroutineDispatchers.kt

@ -22,5 +22,4 @@ data class CoroutineDispatchers( @@ -22,5 +22,4 @@ data class CoroutineDispatchers(
val io: CoroutineDispatcher,
val computation: CoroutineDispatcher,
val main: CoroutineDispatcher,
val diffUpdateDispatcher: CoroutineDispatcher,
)

2
libraries/core/src/main/kotlin/io/element/android/libraries/core/extensions/BasicExtensions.kt

@ -64,7 +64,7 @@ fun String?.insertBeforeLast(insert: String, delimiter: String = "."): String { @@ -64,7 +64,7 @@ fun String?.insertBeforeLast(insert: String, delimiter: String = "."): String {
* Throws if length is < 1.
*/
fun String.ellipsize(length: Int): String {
require(length > 1)
require(length >= 1)
if (this.length <= length) {
return this

17
libraries/deeplink/src/main/kotlin/io/element/android/libraries/deeplink/DeeplinkParser.kt

@ -18,7 +18,6 @@ package io.element.android.libraries.deeplink @@ -18,7 +18,6 @@ package io.element.android.libraries.deeplink
import android.content.Intent
import android.net.Uri
import io.element.android.libraries.core.data.tryOrNull
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.core.ThreadId
@ -37,21 +36,15 @@ class DeeplinkParser @Inject constructor() { @@ -37,21 +36,15 @@ class DeeplinkParser @Inject constructor() {
if (host != HOST) return null
val pathBits = path.orEmpty().split("/").drop(1)
val sessionId = pathBits.elementAtOrNull(0)?.let(::SessionId) ?: return null
val screenPathComponent = pathBits.elementAtOrNull(1)
val roomId = tryOrNull { screenPathComponent?.let(::RoomId) }
return when {
roomId != null -> {
return when (val screenPathComponent = pathBits.elementAtOrNull(1)) {
null -> DeeplinkData.Root(sessionId)
DeepLinkPaths.INVITE_LIST -> DeeplinkData.InviteList(sessionId)
else -> {
val roomId = screenPathComponent.let(::RoomId)
val threadId = pathBits.elementAtOrNull(2)?.let(::ThreadId)
DeeplinkData.Room(sessionId, roomId, threadId)
}
screenPathComponent == DeepLinkPaths.INVITE_LIST -> {
DeeplinkData.InviteList(sessionId)
}
screenPathComponent == null -> {
DeeplinkData.Root(sessionId)
}
else -> null
}
}
}

2
libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/RoomMembershipContentFormatter.kt

@ -34,7 +34,7 @@ class RoomMembershipContentFormatter @Inject constructor( @@ -34,7 +34,7 @@ class RoomMembershipContentFormatter @Inject constructor(
): CharSequence? {
val userId = membershipContent.userId
val memberIsYou = matrixClient.isMe(userId)
return when (val change = membershipContent.change) {
return when (membershipContent.change) {
MembershipChange.JOINED -> if (memberIsYou) {
sp.getString(R.string.state_event_room_join_by_you)
} else {

2
libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt

@ -86,7 +86,7 @@ interface MatrixRoom : Closeable { @@ -86,7 +86,7 @@ interface MatrixRoom : Closeable {
suspend fun toggleReaction(emoji: String, eventId: EventId): Result<Unit>
suspend fun forwardEvent(eventId: EventId, rooms: List<RoomId>): Result<Unit>
suspend fun forwardEvent(eventId: EventId, roomIds: List<RoomId>): Result<Unit>
suspend fun retrySendMessage(transactionId: String): Result<Unit>

3
libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomSummaryDataSource.kt

@ -38,12 +38,13 @@ interface RoomSummaryDataSource { @@ -38,12 +38,13 @@ interface RoomSummaryDataSource {
suspend fun RoomSummaryDataSource.awaitAllRoomsAreLoaded(timeout: Duration = Duration.INFINITE) {
try {
Timber.d("awaitAllRoomsAreLoaded: wait")
withTimeout(timeout) {
allRoomsLoadingState().firstOrNull {
it is RoomSummaryDataSource.LoadingState.Loaded
}
}
} catch (timeoutException: TimeoutCancellationException) {
Timber.v("AwaitAllRooms: no response after $timeout")
Timber.d("awaitAllRoomsAreLoaded: no response after $timeout")
}
}

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

@ -54,7 +54,7 @@ import io.element.android.libraries.matrix.impl.verification.RustSessionVerifica @@ -54,7 +54,7 @@ import io.element.android.libraries.matrix.impl.verification.RustSessionVerifica
import io.element.android.libraries.sessionstorage.api.SessionStore
import io.element.android.services.toolbox.api.systemclock.SystemClock
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
@ -73,6 +73,7 @@ import org.matrix.rustcomponents.sdk.CreateRoomParameters as RustCreateRoomParam @@ -73,6 +73,7 @@ import org.matrix.rustcomponents.sdk.CreateRoomParameters as RustCreateRoomParam
import org.matrix.rustcomponents.sdk.RoomPreset as RustRoomPreset
import org.matrix.rustcomponents.sdk.RoomVisibility as RustRoomVisibility
@OptIn(ExperimentalCoroutinesApi::class)
class RustMatrixClient constructor(
private val client: Client,
private val sessionStore: SessionStore,
@ -85,6 +86,7 @@ class RustMatrixClient constructor( @@ -85,6 +86,7 @@ class RustMatrixClient constructor(
override val sessionId: UserId = UserId(client.userId())
private val roomListService = client.roomListServiceWithEncryption()
private val sessionDispatcher = dispatchers.io.limitedParallelism(64)
private val sessionCoroutineScope = appCoroutineScope.childScope(dispatchers.main, "Session-${sessionId}")
private val verificationService = RustSessionVerificationService()
private val syncService = RustSyncService(roomListService, sessionCoroutineScope)
@ -92,6 +94,7 @@ class RustMatrixClient constructor( @@ -92,6 +94,7 @@ class RustMatrixClient constructor(
client = client,
dispatchers = dispatchers,
)
private val notificationService = RustNotificationService(client)
private val clientDelegate = object : ClientDelegate {
@ -105,7 +108,7 @@ class RustMatrixClient constructor( @@ -105,7 +108,7 @@ class RustMatrixClient constructor(
RustRoomSummaryDataSource(
roomListService = roomListService,
sessionCoroutineScope = sessionCoroutineScope,
coroutineDispatchers = dispatchers,
dispatcher = sessionDispatcher,
)
override val roomSummaryDataSource: RoomSummaryDataSource
@ -150,7 +153,7 @@ class RustMatrixClient constructor( @@ -150,7 +153,7 @@ class RustMatrixClient constructor(
)
}
private suspend fun pairOfRoom(roomId: RoomId): Pair<RoomListItem, Room>? = withContext(dispatchers.io) {
private suspend fun pairOfRoom(roomId: RoomId): Pair<RoomListItem, Room>? = withContext(sessionDispatcher) {
val cachedRoomListItem = roomListService.roomOrNull(roomId.value)
val fullRoom = cachedRoomListItem?.fullRoom()
if (cachedRoomListItem == null || fullRoom == null) {
@ -165,19 +168,19 @@ class RustMatrixClient constructor( @@ -165,19 +168,19 @@ class RustMatrixClient constructor(
return roomId?.let { getRoom(it) }
}
override suspend fun ignoreUser(userId: UserId): Result<Unit> = withContext(dispatchers.io) {
override suspend fun ignoreUser(userId: UserId): Result<Unit> = withContext(sessionDispatcher) {
runCatching {
client.ignoreUser(userId.value)
}
}
override suspend fun unignoreUser(userId: UserId): Result<Unit> = withContext(dispatchers.io) {
override suspend fun unignoreUser(userId: UserId): Result<Unit> = withContext(sessionDispatcher) {
runCatching {
client.unignoreUser(userId.value)
}
}
override suspend fun createRoom(createRoomParams: CreateRoomParameters): Result<RoomId> = withContext(dispatchers.io) {
override suspend fun createRoom(createRoomParams: CreateRoomParameters): Result<RoomId> = withContext(sessionDispatcher) {
runCatching {
val rustParams = RustCreateRoomParameters(
name = createRoomParams.name,
@ -221,14 +224,14 @@ class RustMatrixClient constructor( @@ -221,14 +224,14 @@ class RustMatrixClient constructor(
return createRoom(createRoomParams)
}
override suspend fun getProfile(userId: UserId): Result<MatrixUser> = withContext(Dispatchers.IO) {
override suspend fun getProfile(userId: UserId): Result<MatrixUser> = withContext(sessionDispatcher) {
runCatching {
client.getProfile(userId.value).let(UserProfileMapper::map)
}
}
override suspend fun searchUsers(searchTerm: String, limit: Long): Result<MatrixSearchUserResults> =
withContext(dispatchers.io) {
withContext(sessionDispatcher) {
runCatching {
client.searchUsers(searchTerm, limit.toULong()).let(UserSearchResultMapper::map)
}
@ -260,7 +263,7 @@ class RustMatrixClient constructor( @@ -260,7 +263,7 @@ class RustMatrixClient constructor(
baseDirectory.deleteSessionDirectory(userID = sessionId.value, deleteCryptoDb = false)
}
override suspend fun logout() = withContext(dispatchers.io) {
override suspend fun logout() = withContext(sessionDispatcher) {
try {
client.logout()
} catch (failure: Throwable) {
@ -271,20 +274,20 @@ class RustMatrixClient constructor( @@ -271,20 +274,20 @@ class RustMatrixClient constructor(
sessionStore.removeSession(sessionId.value)
}
override suspend fun loadUserDisplayName(): Result<String> = withContext(dispatchers.io) {
override suspend fun loadUserDisplayName(): Result<String> = withContext(sessionDispatcher) {
runCatching {
client.displayName()
}
}
override suspend fun loadUserAvatarURLString(): Result<String?> = withContext(dispatchers.io) {
override suspend fun loadUserAvatarURLString(): Result<String?> = withContext(sessionDispatcher) {
runCatching {
client.avatarUrl()
}
}
@OptIn(ExperimentalUnsignedTypes::class)
override suspend fun uploadMedia(mimeType: String, data: ByteArray, progressCallback: ProgressCallback?): Result<String> = withContext(dispatchers.io) {
override suspend fun uploadMedia(mimeType: String, data: ByteArray, progressCallback: ProgressCallback?): Result<String> = withContext(sessionDispatcher) {
runCatching {
client.uploadMedia(mimeType, data.toUByteArray().toList(), progressCallback?.toProgressWatcher())
}
@ -305,7 +308,7 @@ class RustMatrixClient constructor( @@ -305,7 +308,7 @@ class RustMatrixClient constructor(
private suspend fun File.getCacheSize(
userID: String,
includeCryptoDb: Boolean = false,
): Long = withContext(dispatchers.io) {
): Long = withContext(sessionDispatcher) {
// Rust sanitises the user ID replacing invalid characters with an _
val sanitisedUserID = userID.replace(":", "_")
val sessionDirectory = File(this@getCacheSize, sanitisedUserID)
@ -327,7 +330,7 @@ class RustMatrixClient constructor( @@ -327,7 +330,7 @@ class RustMatrixClient constructor(
private suspend fun File.deleteSessionDirectory(
userID: String,
deleteCryptoDb: Boolean = false,
): Boolean = withContext(dispatchers.io) {
): Boolean = withContext(sessionDispatcher) {
// Rust sanitises the user ID replacing invalid characters with an _
val sanitisedUserID = userID.replace(":", "_")
val sessionDirectory = File(this@deleteSessionDirectory, sanitisedUserID)

11
libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/RustMediaLoader.kt

@ -20,6 +20,7 @@ import io.element.android.libraries.core.coroutine.CoroutineDispatchers @@ -20,6 +20,7 @@ import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
import io.element.android.libraries.matrix.api.media.MediaFile
import io.element.android.libraries.matrix.api.media.MediaSource
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.withContext
import org.matrix.rustcomponents.sdk.Client
import org.matrix.rustcomponents.sdk.mediaSourceFromUrl
@ -29,10 +30,12 @@ import org.matrix.rustcomponents.sdk.MediaSource as RustMediaSource @@ -29,10 +30,12 @@ import org.matrix.rustcomponents.sdk.MediaSource as RustMediaSource
class RustMediaLoader(
baseCacheDirectory: File,
private val dispatchers: CoroutineDispatchers,
dispatchers: CoroutineDispatchers,
private val innerClient: Client,
) : MatrixMediaLoader {
@OptIn(ExperimentalCoroutinesApi::class)
private val mediaDispatcher = dispatchers.io.limitedParallelism(32)
private val cacheDirectory = File(baseCacheDirectory, "temp/media").apply {
if (!exists()) {
mkdirs()
@ -41,7 +44,7 @@ class RustMediaLoader( @@ -41,7 +44,7 @@ class RustMediaLoader(
@OptIn(ExperimentalUnsignedTypes::class)
override suspend fun loadMediaContent(source: MediaSource): Result<ByteArray> =
withContext(dispatchers.io) {
withContext(mediaDispatcher) {
runCatching {
source.toRustMediaSource().use { source ->
innerClient.getMediaContent(source).toUByteArray().toByteArray()
@ -55,7 +58,7 @@ class RustMediaLoader( @@ -55,7 +58,7 @@ class RustMediaLoader(
width: Long,
height: Long
): Result<ByteArray> =
withContext(dispatchers.io) {
withContext(mediaDispatcher) {
runCatching {
source.toRustMediaSource().use { mediaSource ->
innerClient.getMediaThumbnail(
@ -68,7 +71,7 @@ class RustMediaLoader( @@ -68,7 +71,7 @@ class RustMediaLoader(
}
override suspend fun downloadMediaFile(source: MediaSource, mimeType: String?, body: String?): Result<MediaFile> =
withContext(dispatchers.io) {
withContext(mediaDispatcher) {
runCatching {
source.toRustMediaSource().use { mediaSource ->
val mediaFile = innerClient.getMediaFile(

2
libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RoomListExtensions.kt

@ -62,7 +62,7 @@ fun RoomListService.roomOrNull(roomId: String): RoomListItem? { @@ -62,7 +62,7 @@ fun RoomListService.roomOrNull(roomId: String): RoomListItem? {
return try {
room(roomId)
} catch (exception: RoomListException) {
Timber.e(exception, "Failed finding room with id=$roomId")
Timber.d(exception, "Failed finding room with id=$roomId.")
return null
}
}

65
libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt

@ -23,7 +23,6 @@ import io.element.android.libraries.matrix.api.core.ProgressCallback @@ -23,7 +23,6 @@ import io.element.android.libraries.matrix.api.core.ProgressCallback
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.core.UserId
import io.element.android.libraries.matrix.api.room.location.AssetType
import io.element.android.libraries.matrix.api.media.AudioInfo
import io.element.android.libraries.matrix.api.media.FileInfo
import io.element.android.libraries.matrix.api.media.ImageInfo
@ -32,17 +31,19 @@ import io.element.android.libraries.matrix.api.room.MatrixRoom @@ -32,17 +31,19 @@ import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
import io.element.android.libraries.matrix.api.room.MessageEventType
import io.element.android.libraries.matrix.api.room.StateEventType
import io.element.android.libraries.matrix.api.room.location.AssetType
import io.element.android.libraries.matrix.api.room.roomMembers
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
import io.element.android.libraries.matrix.api.timeline.item.event.EventType
import io.element.android.libraries.matrix.impl.core.toProgressWatcher
import io.element.android.libraries.matrix.impl.room.location.toInner
import io.element.android.libraries.matrix.impl.media.map
import io.element.android.libraries.matrix.impl.room.location.toInner
import io.element.android.libraries.matrix.impl.timeline.RustMatrixTimeline
import io.element.android.libraries.matrix.impl.timeline.backPaginationStatusFlow
import io.element.android.libraries.matrix.impl.timeline.timelineDiffFlow
import io.element.android.services.toolbox.api.systemclock.SystemClock
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@ -62,6 +63,7 @@ import org.matrix.rustcomponents.sdk.messageEventContentFromMarkdown @@ -62,6 +63,7 @@ import org.matrix.rustcomponents.sdk.messageEventContentFromMarkdown
import timber.log.Timber
import java.io.File
@OptIn(ExperimentalCoroutinesApi::class)
class RustMatrixRoom(
override val sessionId: SessionId,
private val roomListItem: RoomListItem,
@ -74,6 +76,11 @@ class RustMatrixRoom( @@ -74,6 +76,11 @@ class RustMatrixRoom(
override val roomId = RoomId(innerRoom.id())
// Create a dispatcher for all room methods...
private val roomDispatcher = coroutineDispatchers.io.limitedParallelism(32)
//...except getMember methods as it could quickly fill the roomDispatcher...
private val roomMembersDispatcher = coroutineDispatchers.io.limitedParallelism(8)
private val roomCoroutineScope = sessionCoroutineScope.childScope(coroutineDispatchers.main, "RoomScope-$roomId")
private val _membersStateFlow = MutableStateFlow<MatrixRoomMembersState>(MatrixRoomMembersState.Unknown)
private val isInit = MutableStateFlow(false)
@ -83,7 +90,7 @@ class RustMatrixRoom( @@ -83,7 +90,7 @@ class RustMatrixRoom(
matrixRoom = this,
innerRoom = innerRoom,
roomCoroutineScope = roomCoroutineScope,
coroutineDispatchers = coroutineDispatchers
dispatcher = roomDispatcher
)
}
@ -105,7 +112,7 @@ class RustMatrixRoom( @@ -105,7 +112,7 @@ class RustMatrixRoom(
timelineLimit = null
)
roomListItem.subscribe(settings)
roomCoroutineScope.launch(coroutineDispatchers.computation) {
roomCoroutineScope.launch(roomDispatcher) {
innerRoom.timelineDiffFlow { initialList ->
_timeline.postItems(initialList)
}.onEach {
@ -175,7 +182,7 @@ class RustMatrixRoom( @@ -175,7 +182,7 @@ class RustMatrixRoom(
override val activeMemberCount: Long
get() = innerRoom.activeMembersCount().toLong()
override suspend fun updateMembers(): Result<Unit> = withContext(coroutineDispatchers.io) {
override suspend fun updateMembers(): Result<Unit> = withContext(roomMembersDispatcher) {
val currentState = _membersStateFlow.value
val currentMembers = currentState.roomMembers()
_membersStateFlow.value = MatrixRoomMembersState.Pending(prevRoomMembers = currentMembers)
@ -189,20 +196,20 @@ class RustMatrixRoom( @@ -189,20 +196,20 @@ class RustMatrixRoom(
}
override suspend fun userDisplayName(userId: UserId): Result<String?> =
withContext(coroutineDispatchers.io) {
withContext(roomDispatcher) {
runCatching {
innerRoom.memberDisplayName(userId.value)
}
}
override suspend fun userAvatarUrl(userId: UserId): Result<String?> =
withContext(coroutineDispatchers.io) {
withContext(roomDispatcher) {
runCatching {
innerRoom.memberAvatarUrl(userId.value)
}
}
override suspend fun sendMessage(message: String): Result<Unit> = withContext(coroutineDispatchers.io) {
override suspend fun sendMessage(message: String): Result<Unit> = withContext(roomDispatcher) {
val transactionId = genTransactionId()
messageEventContentFromMarkdown(message).use { content ->
runCatching {
@ -211,7 +218,7 @@ class RustMatrixRoom( @@ -211,7 +218,7 @@ class RustMatrixRoom(
}
}
override suspend fun editMessage(originalEventId: EventId?, transactionId: String?, message: String): Result<Unit> = withContext(coroutineDispatchers.io) {
override suspend fun editMessage(originalEventId: EventId?, transactionId: String?, message: String): Result<Unit> = withContext(roomDispatcher) {
if (originalEventId != null) {
runCatching {
innerRoom.edit(/* TODO use content */ message, originalEventId.value, transactionId)
@ -224,7 +231,7 @@ class RustMatrixRoom( @@ -224,7 +231,7 @@ class RustMatrixRoom(
}
}
override suspend fun replyMessage(eventId: EventId, message: String): Result<Unit> = withContext(coroutineDispatchers.io) {
override suspend fun replyMessage(eventId: EventId, message: String): Result<Unit> = withContext(roomDispatcher) {
val transactionId = genTransactionId()
// val content = messageEventContentFromMarkdown(message)
runCatching {
@ -232,50 +239,50 @@ class RustMatrixRoom( @@ -232,50 +239,50 @@ class RustMatrixRoom(
}
}
override suspend fun redactEvent(eventId: EventId, reason: String?) = withContext(coroutineDispatchers.io) {
override suspend fun redactEvent(eventId: EventId, reason: String?) = withContext(roomDispatcher) {
val transactionId = genTransactionId()
runCatching {
innerRoom.redact(eventId.value, reason, transactionId)
}
}
override suspend fun leave(): Result<Unit> = withContext(coroutineDispatchers.io) {
override suspend fun leave(): Result<Unit> = withContext(roomDispatcher) {
runCatching {
innerRoom.leave()
}
}
override suspend fun acceptInvitation(): Result<Unit> = withContext(coroutineDispatchers.io) {
override suspend fun acceptInvitation(): Result<Unit> = withContext(roomDispatcher) {
runCatching {
innerRoom.acceptInvitation()
}
}
override suspend fun rejectInvitation(): Result<Unit> = withContext(coroutineDispatchers.io) {
override suspend fun rejectInvitation(): Result<Unit> = withContext(roomDispatcher) {
runCatching {
innerRoom.rejectInvitation()
}
}
override suspend fun inviteUserById(id: UserId): Result<Unit> = withContext(coroutineDispatchers.io) {
override suspend fun inviteUserById(id: UserId): Result<Unit> = withContext(roomDispatcher) {
runCatching {
innerRoom.inviteUserById(id.value)
}
}
override suspend fun canInvite(): Result<Boolean> = withContext(coroutineDispatchers.io) {
override suspend fun canInvite(): Result<Boolean> = withContext(roomMembersDispatcher) {
runCatching {
innerRoom.member(sessionId.value).use(RoomMember::canInvite)
}
}
override suspend fun canSendStateEvent(type: StateEventType): Result<Boolean> = withContext(coroutineDispatchers.io) {
override suspend fun canSendStateEvent(type: StateEventType): Result<Boolean> = withContext(roomMembersDispatcher) {
runCatching {
innerRoom.member(sessionId.value).use { it.canSendState(type.map()) }
}
}
override suspend fun canSendEvent(type: MessageEventType): Result<Boolean> = withContext(coroutineDispatchers.io) {
override suspend fun canSendEvent(type: MessageEventType): Result<Boolean> = withContext(roomMembersDispatcher) {
runCatching {
innerRoom.member(sessionId.value).use { it.canSendMessage(type.map()) }
}
@ -305,13 +312,13 @@ class RustMatrixRoom( @@ -305,13 +312,13 @@ class RustMatrixRoom(
}
}
override suspend fun toggleReaction(emoji: String, eventId: EventId): Result<Unit> = withContext(coroutineDispatchers.io) {
override suspend fun toggleReaction(emoji: String, eventId: EventId): Result<Unit> = withContext(roomDispatcher) {
runCatching {
innerRoom.toggleReaction(key = emoji, eventId = eventId.value)
}
}
override suspend fun forwardEvent(eventId: EventId, roomIds: List<RoomId>): Result<Unit> = withContext(coroutineDispatchers.io) {
override suspend fun forwardEvent(eventId: EventId, roomIds: List<RoomId>): Result<Unit> = withContext(roomDispatcher) {
runCatching {
roomContentForwarder.forward(fromRoom = innerRoom, eventId = eventId, toRoomIds = roomIds)
}.onFailure {
@ -320,14 +327,14 @@ class RustMatrixRoom( @@ -320,14 +327,14 @@ class RustMatrixRoom(
}
override suspend fun retrySendMessage(transactionId: String): Result<Unit> =
withContext(coroutineDispatchers.io) {
withContext(roomDispatcher) {
runCatching {
innerRoom.retrySend(transactionId)
}
}
override suspend fun cancelSend(transactionId: String): Result<Unit> =
withContext(coroutineDispatchers.io) {
withContext(roomDispatcher) {
runCatching {
innerRoom.cancelSend(transactionId)
}
@ -335,40 +342,40 @@ class RustMatrixRoom( @@ -335,40 +342,40 @@ class RustMatrixRoom(
@OptIn(ExperimentalUnsignedTypes::class)
override suspend fun updateAvatar(mimeType: String, data: ByteArray): Result<Unit> =
withContext(coroutineDispatchers.io) {
withContext(roomDispatcher) {
runCatching {
innerRoom.uploadAvatar(mimeType, data.toUByteArray().toList())
}
}
override suspend fun removeAvatar(): Result<Unit> =
withContext(coroutineDispatchers.io) {
withContext(roomDispatcher) {
runCatching {
innerRoom.removeAvatar()
}
}
override suspend fun setName(name: String): Result<Unit> =
withContext(coroutineDispatchers.io) {
withContext(roomDispatcher) {
runCatching {
innerRoom.setName(name)
}
}
override suspend fun setTopic(topic: String): Result<Unit> =
withContext(coroutineDispatchers.io) {
withContext(roomDispatcher) {
runCatching {
innerRoom.setTopic(topic)
}
}
private suspend fun fetchMembers() = withContext(coroutineDispatchers.io) {
private suspend fun fetchMembers() = withContext(roomDispatcher) {
runCatching {
innerRoom.fetchMembers()
}
}
override suspend fun reportContent(eventId: EventId, reason: String, blockUserId: UserId?): Result<Unit> = withContext(coroutineDispatchers.io) {
override suspend fun reportContent(eventId: EventId, reason: String, blockUserId: UserId?): Result<Unit> = withContext(roomDispatcher) {
runCatching {
innerRoom.reportContent(eventId = eventId.value, score = null, reason = reason)
if (blockUserId != null) {
@ -383,7 +390,7 @@ class RustMatrixRoom( @@ -383,7 +390,7 @@ class RustMatrixRoom(
description: String?,
zoomLevel: Int?,
assetType: AssetType?,
): Result<Unit> = withContext(coroutineDispatchers.io) {
): Result<Unit> = withContext(roomDispatcher) {
runCatching {
innerRoom.sendLocation(
body = body,

6
libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustRoomSummaryDataSource.kt

@ -16,9 +16,9 @@ @@ -16,9 +16,9 @@
package io.element.android.libraries.matrix.impl.room
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.matrix.api.room.RoomSummary
import io.element.android.libraries.matrix.api.room.RoomSummaryDataSource
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
@ -41,7 +41,7 @@ import timber.log.Timber @@ -41,7 +41,7 @@ import timber.log.Timber
internal class RustRoomSummaryDataSource(
private val roomListService: RoomListService,
private val sessionCoroutineScope: CoroutineScope,
coroutineDispatchers: CoroutineDispatchers,
dispatcher: CoroutineDispatcher,
roomSummaryDetailsFactory: RoomSummaryDetailsFactory = RoomSummaryDetailsFactory(),
) : RoomSummaryDataSource {
@ -53,7 +53,7 @@ internal class RustRoomSummaryDataSource( @@ -53,7 +53,7 @@ internal class RustRoomSummaryDataSource(
private val inviteRoomsListProcessor = RoomSummaryListProcessor(inviteRooms, roomListService, roomSummaryDetailsFactory, shouldFetchFullRoom = true)
init {
sessionCoroutineScope.launch(coroutineDispatchers.computation) {
sessionCoroutineScope.launch(dispatcher) {
val allRooms = roomListService.allRooms()
allRooms
.observeEntriesWithProcessor(allRoomsListProcessor)

10
libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustMatrixTimeline.kt

@ -16,7 +16,6 @@ @@ -16,7 +16,6 @@
package io.element.android.libraries.matrix.impl.timeline
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
@ -25,6 +24,7 @@ import io.element.android.libraries.matrix.impl.timeline.item.event.EventMessage @@ -25,6 +24,7 @@ import io.element.android.libraries.matrix.impl.timeline.item.event.EventMessage
import io.element.android.libraries.matrix.impl.timeline.item.event.EventTimelineItemMapper
import io.element.android.libraries.matrix.impl.timeline.item.event.TimelineEventContentMapper
import io.element.android.libraries.matrix.impl.timeline.item.virtual.VirtualTimelineItemMapper
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.Flow
@ -45,7 +45,7 @@ class RustMatrixTimeline( @@ -45,7 +45,7 @@ class RustMatrixTimeline(
roomCoroutineScope: CoroutineScope,
private val matrixRoom: MatrixRoom,
private val innerRoom: Room,
private val coroutineDispatchers: CoroutineDispatchers,
private val dispatcher: CoroutineDispatcher,
) : MatrixTimeline {
private val _timelineItems: MutableStateFlow<List<MatrixTimelineItem>> =
@ -109,13 +109,13 @@ class RustMatrixTimeline( @@ -109,13 +109,13 @@ class RustMatrixTimeline(
}
}
override suspend fun fetchDetailsForEvent(eventId: EventId): Result<Unit> = withContext(coroutineDispatchers.io) {
override suspend fun fetchDetailsForEvent(eventId: EventId): Result<Unit> = withContext(dispatcher) {
runCatching {
innerRoom.fetchDetailsForEvent(eventId.value)
}
}
override suspend fun paginateBackwards(requestSize: Int, untilNumberOfItems: Int): Result<Unit> = withContext(coroutineDispatchers.io) {
override suspend fun paginateBackwards(requestSize: Int, untilNumberOfItems: Int): Result<Unit> = withContext(dispatcher) {
runCatching {
Timber.v("Start back paginating for room ${matrixRoom.roomId} ")
val paginationOptions = PaginationOptions.UntilNumItems(
@ -131,7 +131,7 @@ class RustMatrixTimeline( @@ -131,7 +131,7 @@ class RustMatrixTimeline(
}
}
override suspend fun sendReadReceipt(eventId: EventId) = withContext(coroutineDispatchers.io) {
override suspend fun sendReadReceipt(eventId: EventId) = withContext(dispatcher) {
runCatching {
innerRoom.sendReadReceipt(eventId = eventId.value)
}

5
libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt

@ -38,6 +38,7 @@ import io.element.android.libraries.matrix.test.room.FakeMatrixRoom @@ -38,6 +38,7 @@ import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.matrix.test.room.FakeRoomSummaryDataSource
import io.element.android.libraries.matrix.test.sync.FakeSyncService
import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService
import io.element.android.tests.testutils.simulateLongTask
import kotlinx.coroutines.delay
class FakeMatrixClient(
@ -72,11 +73,11 @@ class FakeMatrixClient( @@ -72,11 +73,11 @@ class FakeMatrixClient(
return findDmResult
}
override suspend fun ignoreUser(userId: UserId): Result<Unit> {
override suspend fun ignoreUser(userId: UserId): Result<Unit> = simulateLongTask {
return ignoreUserResult
}
override suspend fun unignoreUser(userId: UserId): Result<Unit> {
override suspend fun unignoreUser(userId: UserId): Result<Unit> = simulateLongTask {
return unignoreUserResult
}

2
libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt

@ -239,7 +239,7 @@ class FakeMatrixRoom( @@ -239,7 +239,7 @@ class FakeMatrixRoom(
override suspend fun sendFile(file: File, fileInfo: FileInfo, progressCallback: ProgressCallback?): Result<Unit> = fakeSendMedia(progressCallback)
override suspend fun forwardEvent(eventId: EventId, rooms: List<RoomId>): Result<Unit> = simulateLongTask {
override suspend fun forwardEvent(eventId: EventId, roomIds: List<RoomId>): Result<Unit> = simulateLongTask {
forwardEventResult
}

2
libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/di/MatrixUIBindings.kt

@ -19,10 +19,8 @@ package io.element.android.libraries.matrix.ui.di @@ -19,10 +19,8 @@ package io.element.android.libraries.matrix.ui.di
import com.squareup.anvil.annotations.ContributesTo
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.ui.media.LoggedInImageLoaderFactory
import io.element.android.libraries.matrix.ui.media.NotLoggedInImageLoaderFactory
@ContributesTo(SessionScope::class)
interface MatrixUIBindings {
fun loggedInImageLoaderFactory(): LoggedInImageLoaderFactory
fun notLoggedInImageLoaderFactory(): NotLoggedInImageLoaderFactory
}

2
libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationFactory.kt

@ -123,7 +123,7 @@ class NotificationFactory @Inject constructor( @@ -123,7 +123,7 @@ class NotificationFactory @Inject constructor(
val roomMeta = roomNotifications.filterIsInstance<RoomNotification.Message>().map { it.meta }
val invitationMeta = invitationNotifications.filterIsInstance<OneShotNotification.Append>().map { it.meta }
val simpleMeta = simpleNotifications.filterIsInstance<OneShotNotification.Append>().map { it.meta }
val fallbackMeta = simpleNotifications.filterIsInstance<OneShotNotification.Append>().map { it.meta }
val fallbackMeta = fallbackNotifications.filterIsInstance<OneShotNotification.Append>().map { it.meta }
return when {
roomMeta.isEmpty() && invitationMeta.isEmpty() && simpleMeta.isEmpty() -> SummaryNotification.Removed
else -> SummaryNotification.Update(

2
libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/PendingIntentFactory.kt

@ -106,7 +106,7 @@ class PendingIntentFactory @Inject constructor( @@ -106,7 +106,7 @@ class PendingIntentFactory @Inject constructor(
fun createDismissEventPendingIntent(sessionId: SessionId, roomId: RoomId, eventId: EventId): PendingIntent {
val intent = Intent(context, NotificationBroadcastReceiver::class.java)
intent.action = actionIds.dismissEvent
intent.data = createIgnoredUri("deleteEvent/$sessionId/$roomId")
intent.data = createIgnoredUri("deleteEvent/$sessionId/$roomId/$eventId")
intent.putExtra(NotificationBroadcastReceiver.KEY_SESSION_ID, sessionId.value)
intent.putExtra(NotificationBroadcastReceiver.KEY_ROOM_ID, roomId.value)
intent.putExtra(NotificationBroadcastReceiver.KEY_EVENT_ID, eventId.value)

3
samples/minimal/src/main/kotlin/io/element/android/samples/minimal/Singleton.kt

@ -22,10 +22,8 @@ import io.element.android.libraries.matrix.api.tracing.TracingConfigurations @@ -22,10 +22,8 @@ import io.element.android.libraries.matrix.api.tracing.TracingConfigurations
import kotlinx.coroutines.CoroutineName
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.plus
import timber.log.Timber
import java.util.concurrent.Executors
object Singleton {
@ -39,6 +37,5 @@ object Singleton { @@ -39,6 +37,5 @@ object Singleton {
io = Dispatchers.IO,
computation = Dispatchers.Default,
main = Dispatchers.Main,
diffUpdateDispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher(),
)
}

2
tests/testutils/src/main/kotlin/io/element/android/tests/testutils/TestCoroutineDispatchers.kt

@ -37,12 +37,10 @@ fun TestScope.testCoroutineDispatchers( @@ -37,12 +37,10 @@ fun TestScope.testCoroutineDispatchers(
io = UnconfinedTestDispatcher(testScheduler),
computation = UnconfinedTestDispatcher(testScheduler),
main = UnconfinedTestDispatcher(testScheduler),
diffUpdateDispatcher = UnconfinedTestDispatcher(testScheduler),
)
false -> CoroutineDispatchers(
io = StandardTestDispatcher(testScheduler),
computation = StandardTestDispatcher(testScheduler),
main = StandardTestDispatcher(testScheduler),
diffUpdateDispatcher = StandardTestDispatcher(testScheduler),
)
}

BIN
tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members.details_null_DefaultGroup_RoomMemberDetailsViewDarkPreview--3_3_null_5,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members.details_null_DefaultGroup_RoomMemberDetailsViewLightPreview--2_2_null_5,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.
Loading…
Cancel
Save