Merge pull request #5432 from element-hq/feature/bma/leaveSpace

Leave space: use SDK API.
This commit is contained in:
Benoit Marty 2025-10-03 16:43:45 +02:00 committed by GitHub
commit 0c63d0cb4f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
48 changed files with 602 additions and 210 deletions

View file

@ -10,6 +10,7 @@ package io.element.android.features.space.impl.leave
import io.element.android.libraries.matrix.api.core.RoomId
sealed interface LeaveSpaceEvents {
data object Retry : LeaveSpaceEvents
data object SelectAllRooms : LeaveSpaceEvents
data object DeselectAllRooms : LeaveSpaceEvents
data class ToggleRoomSelection(val roomId: RoomId) : LeaveSpaceEvents

View file

@ -9,21 +9,39 @@ package io.element.android.features.space.impl.leave
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.lifecycle.subscribe
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.features.space.api.SpaceEntryPoint
import io.element.android.features.space.impl.di.SpaceFlowScope
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.matrix.api.MatrixClient
@ContributesNode(SpaceFlowScope::class)
@AssistedInject
class LeaveSpaceNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val presenter: LeaveSpacePresenter,
matrixClient: MatrixClient,
presenterFactory: LeaveSpacePresenter.Factory,
) : Node(buildContext, plugins = plugins) {
private val inputs: SpaceEntryPoint.Inputs = inputs()
private val leaveSpaceHandle = matrixClient.spaceService.getLeaveSpaceHandle(inputs.roomId)
private val presenter: LeaveSpacePresenter = presenterFactory.create(leaveSpaceHandle)
override fun onBuilt() {
super.onBuilt()
lifecycle.subscribe(
onDestroy = {
leaveSpaceHandle.close()
}
)
}
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()

View file

@ -8,92 +8,119 @@
package io.element.android.features.space.impl.leave
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import dev.zacsweers.metro.Inject
import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject
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.map
import io.element.android.libraries.architecture.runUpdatingState
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.spaces.SpaceRoom
import io.element.android.libraries.matrix.api.spaces.SpaceRoomList
import io.element.android.libraries.matrix.api.spaces.LeaveSpaceHandle
import io.element.android.libraries.matrix.api.spaces.LeaveSpaceRoom
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableSet
import kotlinx.collections.immutable.persistentSetOf
import kotlinx.collections.immutable.toPersistentList
import kotlinx.collections.immutable.toPersistentSet
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import kotlin.jvm.optionals.getOrNull
@Inject
@AssistedInject
class LeaveSpacePresenter(
private val spaceRoomList: SpaceRoomList,
@Assisted private val leaveSpaceHandle: LeaveSpaceHandle,
) : Presenter<LeaveSpaceState> {
@AssistedFactory
fun interface Factory {
fun create(leaveSpaceHandle: LeaveSpaceHandle): LeaveSpacePresenter
}
data class LeaveSpaceRooms(
val current: LeaveSpaceRoom?,
val others: List<LeaveSpaceRoom>,
)
@Composable
override fun present(): LeaveSpaceState {
val coroutineScope = rememberCoroutineScope()
val currentSpace by spaceRoomList.currentSpaceFlow.collectAsState()
var retryCount by remember { mutableIntStateOf(0) }
val leaveSpaceAction = remember {
mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized)
}
val selectedRoomIds = remember {
mutableStateOf<ImmutableSet<RoomId>>(persistentSetOf())
var selectedRoomIds by remember {
mutableStateOf<Collection<RoomId>>(setOf())
}
val joinedSpaceRooms by produceState(emptyList()) {
// TODO Get the joined room from the SDK, should also have the isLastAdmin boolean
val rooms = emptyList<SpaceRoom>()
// By default select all rooms
selectedRoomIds.value = rooms.map { it.roomId }.toPersistentSet()
value = rooms
var leaveSpaceRooms by remember {
mutableStateOf<AsyncData<LeaveSpaceRooms>>(AsyncData.Loading())
}
val selectableSpaceRooms by produceState<AsyncData<ImmutableList<SelectableSpaceRoom>>>(
initialValue = AsyncData.Uninitialized,
key1 = joinedSpaceRooms,
key2 = selectedRoomIds.value,
) {
value = AsyncData.Success(
joinedSpaceRooms.map {
SelectableSpaceRoom(
spaceRoom = it,
// TODO Get this value from the SDK
isLastAdmin = false,
isSelected = selectedRoomIds.value.contains(it.roomId),
LaunchedEffect(retryCount) {
val rooms = leaveSpaceHandle.rooms()
val (currentRoom, otherRooms) = rooms.getOrNull()
.orEmpty()
.partition { it.spaceRoom.roomId == leaveSpaceHandle.id }
// By default select all rooms that can be left
selectedRoomIds = otherRooms
.filter { it.isLastAdmin.not() }
.map { it.spaceRoom.roomId }
leaveSpaceRooms = rooms.fold(
onSuccess = {
AsyncData.Success(
LeaveSpaceRooms(
current = currentRoom.firstOrNull(),
others = otherRooms.toImmutableList(),
)
)
}.toPersistentList()
},
onFailure = { AsyncData.Failure(it) }
)
}
var selectableSpaceRooms by remember {
mutableStateOf<AsyncData<ImmutableList<SelectableSpaceRoom>>>(AsyncData.Loading())
}
LaunchedEffect(selectedRoomIds, leaveSpaceRooms) {
selectableSpaceRooms = leaveSpaceRooms.map {
it?.others.orEmpty().map { room ->
SelectableSpaceRoom(
spaceRoom = room.spaceRoom,
isLastAdmin = room.isLastAdmin,
isSelected = selectedRoomIds.contains(room.spaceRoom.roomId),
)
}.toImmutableList()
}
}
fun handleEvents(event: LeaveSpaceEvents) {
when (event) {
LeaveSpaceEvents.Retry -> {
leaveSpaceRooms = AsyncData.Loading()
retryCount += 1
}
LeaveSpaceEvents.DeselectAllRooms -> {
selectedRoomIds.value = persistentSetOf()
selectedRoomIds = persistentSetOf()
}
LeaveSpaceEvents.SelectAllRooms -> {
selectedRoomIds.value = selectableSpaceRooms.dataOrNull()
selectedRoomIds = selectableSpaceRooms.dataOrNull()
.orEmpty()
.filter { it.isLastAdmin.not() }
.map { it.spaceRoom.roomId }
.toPersistentSet()
}
is LeaveSpaceEvents.ToggleRoomSelection -> {
val currentSet = selectedRoomIds.value
selectedRoomIds.value = if (currentSet.contains(event.roomId)) {
currentSet - event.roomId
selectedRoomIds = if (selectedRoomIds.contains(event.roomId)) {
selectedRoomIds - event.roomId
} else {
currentSet + event.roomId
selectedRoomIds + event.roomId
}
.toPersistentSet()
}
LeaveSpaceEvents.LeaveSpace -> coroutineScope.leaveSpace(
leaveSpaceAction = leaveSpaceAction,
selectedRoomIds = selectedRoomIds.value,
selectedRoomIds = selectedRoomIds,
)
LeaveSpaceEvents.CloseError -> {
leaveSpaceAction.value = AsyncAction.Uninitialized
@ -102,7 +129,8 @@ class LeaveSpacePresenter(
}
return LeaveSpaceState(
spaceName = currentSpace.getOrNull()?.name,
spaceName = leaveSpaceRooms.dataOrNull()?.current?.spaceRoom?.name,
isLastAdmin = leaveSpaceRooms.dataOrNull()?.current?.isLastAdmin == true,
selectableSpaceRooms = selectableSpaceRooms,
leaveSpaceAction = leaveSpaceAction.value,
eventSink = ::handleEvents,
@ -111,11 +139,10 @@ class LeaveSpacePresenter(
private fun CoroutineScope.leaveSpace(
leaveSpaceAction: MutableState<AsyncAction<Unit>>,
@Suppress("unused") selectedRoomIds: Set<RoomId>,
selectedRoomIds: Collection<RoomId>,
) = launch {
runUpdatingState(leaveSpaceAction) {
// TODO SDK API call to leave all the rooms and space
Result.failure(Exception("Not implemented"))
leaveSpaceHandle.leave(selectedRoomIds.toList())
}
}
}

View file

@ -13,6 +13,7 @@ import kotlinx.collections.immutable.ImmutableList
data class LeaveSpaceState(
val spaceName: String?,
val isLastAdmin: Boolean,
val selectableSpaceRooms: AsyncData<ImmutableList<SelectableSpaceRoom>>,
val leaveSpaceAction: AsyncAction<Unit>,
val eventSink: (LeaveSpaceEvents) -> Unit,
@ -25,7 +26,12 @@ data class LeaveSpaceState(
/**
* True if we should show the quick action to select/deselect all rooms.
*/
val showQuickAction = selectableRooms.isNotEmpty()
val showQuickAction = isLastAdmin.not() && selectableRooms.isNotEmpty()
/**
* True if we should show the leave button.
*/
val showLeaveButton = isLastAdmin.not() && selectableSpaceRooms is AsyncData.Success
/**
* True if there all the selectable rooms are selected.

View file

@ -105,15 +105,20 @@ class LeaveSpaceStateProvider : PreviewParameterProvider<LeaveSpaceState> {
aLeaveSpaceState(
selectableSpaceRooms = AsyncData.Failure(Exception("An error")),
),
aLeaveSpaceState(
isLastAdmin = true,
),
)
}
fun aLeaveSpaceState(
spaceName: String? = "Space name",
isLastAdmin: Boolean = false,
selectableSpaceRooms: AsyncData<ImmutableList<SelectableSpaceRoom>> = AsyncData.Uninitialized,
leaveSpaceAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
) = LeaveSpaceState(
spaceName = spaceName,
isLastAdmin = isLastAdmin,
selectableSpaceRooms = selectableSpaceRooms,
leaveSpaceAction = leaveSpaceAction,
eventSink = { }

View file

@ -9,8 +9,10 @@
package io.element.android.features.space.impl.leave
import androidx.annotation.StringRes
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
@ -71,6 +73,12 @@ fun LeaveSpaceView(
) {
Scaffold(
modifier = modifier,
topBar = {
LeaveSpaceHeader(
state = state,
onBackClick = onCancel,
)
},
containerColor = ElementTheme.colors.bgCanvasDefault,
) { padding ->
Column(
@ -79,45 +87,44 @@ fun LeaveSpaceView(
.imePadding()
.consumeWindowInsets(padding)
.fillMaxSize()
.padding(16.dp)
) {
LeaveSpaceHeader(
state = state,
onBackClick = onCancel,
)
LazyColumn(
modifier = Modifier
.weight(1f),
) {
when (state.selectableSpaceRooms) {
is AsyncData.Success -> {
// List rooms where the user is the only admin
state.selectableSpaceRooms.data.forEach { selectableSpaceRoom ->
item {
SpaceItem(
selectableSpaceRoom = selectableSpaceRoom,
showCheckBox = state.hasOnlyLastAdminRoom.not(),
onClick = {
state.eventSink(LeaveSpaceEvents.ToggleRoomSelection(selectableSpaceRoom.spaceRoom.roomId))
}
)
if (state.isLastAdmin.not()) {
when (state.selectableSpaceRooms) {
is AsyncData.Success -> {
// List rooms where the user is the only admin
state.selectableSpaceRooms.data.forEach { selectableSpaceRoom ->
item {
SpaceItem(
selectableSpaceRoom = selectableSpaceRoom,
showCheckBox = state.hasOnlyLastAdminRoom.not(),
onClick = {
state.eventSink(LeaveSpaceEvents.ToggleRoomSelection(selectableSpaceRoom.spaceRoom.roomId))
}
)
}
}
}
}
is AsyncData.Failure -> item {
AsyncFailure(
throwable = state.selectableSpaceRooms.error,
onRetry = null,
)
}
is AsyncData.Loading,
AsyncData.Uninitialized -> item {
AsyncLoading()
is AsyncData.Failure -> item {
AsyncFailure(
throwable = state.selectableSpaceRooms.error,
onRetry = {
state.eventSink(LeaveSpaceEvents.Retry)
},
)
}
is AsyncData.Loading,
AsyncData.Uninitialized -> item {
AsyncLoading()
}
}
}
}
LeaveSpaceButtons(
showLeaveButton = state.selectableSpaceRooms is AsyncData.Success,
showLeaveButton = state.showLeaveButton,
selectedRoomsCount = state.selectedRoomsCount,
onLeaveSpace = {
state.eventSink(LeaveSpaceEvents.LeaveSpace)
@ -130,6 +137,7 @@ fun LeaveSpaceView(
AsyncActionView(
async = state.leaveSpaceAction,
onSuccess = { /* Nothing to do, the screen will be dismissed automatically */ },
errorMessage = { stringResource(CommonStrings.error_unknown) },
onErrorDismiss = { state.eventSink(LeaveSpaceEvents.CloseError) },
)
}
@ -150,11 +158,13 @@ private fun LeaveSpaceHeader(
modifier = Modifier.padding(top = 0.dp, bottom = 8.dp, start = 24.dp, end = 24.dp),
iconStyle = BigIcon.Style.AlertSolid,
title = stringResource(
R.string.screen_leave_space_title,
if (state.isLastAdmin) R.string.screen_leave_space_title_last_admin else R.string.screen_leave_space_title,
state.spaceName ?: stringResource(CommonStrings.common_space)
),
subTitle =
if (state.selectableSpaceRooms is AsyncData.Success && state.selectableSpaceRooms.data.isNotEmpty()) {
if (state.isLastAdmin) {
stringResource(R.string.screen_leave_space_subtitle_last_admin)
} else if (state.selectableSpaceRooms is AsyncData.Success && state.selectableSpaceRooms.data.isNotEmpty()) {
if (state.hasOnlyLastAdminRoom) {
stringResource(R.string.screen_leave_space_subtitle_only_last_admin)
} else {
@ -166,34 +176,35 @@ private fun LeaveSpaceHeader(
)
if (state.showQuickAction) {
if (state.areAllSelected) {
Text(
modifier = Modifier
.align(Alignment.End)
.clickable {
state.eventSink(LeaveSpaceEvents.DeselectAllRooms)
}
.padding(vertical = 8.dp, horizontal = 8.dp),
text = stringResource(CommonStrings.common_deselect_all),
color = ElementTheme.colors.textActionPrimary,
style = ElementTheme.typography.fontBodyMdMedium,
)
QuickActionButton(CommonStrings.common_deselect_all) {
state.eventSink(LeaveSpaceEvents.DeselectAllRooms)
}
} else {
Text(
modifier = Modifier
.align(Alignment.End)
.clickable {
state.eventSink(LeaveSpaceEvents.SelectAllRooms)
}
.padding(vertical = 8.dp, horizontal = 8.dp),
text = stringResource(CommonStrings.common_select_all),
color = ElementTheme.colors.textActionPrimary,
style = ElementTheme.typography.fontBodyMdMedium,
)
QuickActionButton(resId = CommonStrings.common_select_all) {
state.eventSink(LeaveSpaceEvents.SelectAllRooms)
}
}
}
}
}
@Composable
private fun ColumnScope.QuickActionButton(
@StringRes resId: Int,
onClick: () -> Unit,
) {
Text(
modifier = Modifier
.align(Alignment.End)
.padding(end = 8.dp)
.clickable(onClick = onClick)
.padding(8.dp),
text = stringResource(resId),
color = ElementTheme.colors.textActionPrimary,
style = ElementTheme.typography.fontBodyMdMedium,
)
}
@Composable
private fun LeaveSpaceButtons(
showLeaveButton: Boolean,
@ -202,7 +213,7 @@ private fun LeaveSpaceButtons(
onCancel: () -> Unit,
) {
ButtonColumnMolecule(
modifier = Modifier.padding(top = 16.dp)
modifier = Modifier.padding(16.dp)
) {
if (showLeaveButton) {
val text = if (selectedRoomsCount > 0) {
@ -218,6 +229,8 @@ private fun LeaveSpaceButtons(
destructive = true,
)
}
// TODO For least admin space, add a button to open the settings.
// See https://www.figma.com/design/kcnHxunG1LDWXsJhaNuiHz/ER-145--Spaces-on-Element-X?node-id=4622-59600
TextButton(
modifier = Modifier.fillMaxWidth(),
text = stringResource(CommonStrings.action_cancel),
@ -300,18 +313,15 @@ private fun SpaceItem(
)
}
// Number of members
val subTitle = buildString {
append(
pluralStringResource(
CommonPlurals.common_member_count,
room.numJoinedMembers,
room.numJoinedMembers
)
)
if (selectableSpaceRoom.isLastAdmin) {
append(" ")
append(stringResource(R.string.screen_leave_space_last_admin_info))
}
val membersCount = pluralStringResource(
CommonPlurals.common_member_count,
room.numJoinedMembers,
room.numJoinedMembers
)
val subTitle = if (selectableSpaceRoom.isLastAdmin) {
stringResource(R.string.screen_leave_space_last_admin_info, membersCount)
} else {
membersCount
}
Text(
modifier = Modifier.padding(end = 16.dp),

View file

@ -202,7 +202,7 @@ private fun LoadingMoreIndicator(
private fun SpaceViewTopBar(
currentSpace: SpaceRoom?,
onBackClick: () -> Unit,
@Suppress("unused") onLeaveSpaceClick: () -> Unit,
onLeaveSpaceClick: () -> Unit,
onShareSpace: () -> Unit,
modifier: Modifier = Modifier,
) {
@ -247,23 +247,25 @@ private fun SpaceViewTopBar(
)
}
)
/*
// TODO re-enable when we have SDK APIs to leave a space
DropdownMenuItem(
onClick = {
showMenu = false
onLeaveSpaceClick()
},
text = { Text(stringResource(id = CommonStrings.action_leave)) },
text = {
Text(
text = stringResource(id = CommonStrings.action_leave),
color = ElementTheme.colors.textCriticalPrimary,
)
},
leadingIcon = {
Icon(
imageVector = CompoundIcons.Leave(),
tint = ElementTheme.colors.iconSecondary,
tint = ElementTheme.colors.iconCriticalPrimary,
contentDescription = null,
)
}
)
*/
}
},
)

View file

@ -1,6 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_leave_space_last_admin_info">"(Správce)"</string>
<plurals name="screen_leave_space_submit">
<item quantity="one">"Opustit %1$d místnost a prostor"</item>
<item quantity="few">"Opustit %1$d místnosti a prostor"</item>

View file

@ -1,6 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_leave_space_last_admin_info">"Administrator"</string>
<plurals name="screen_leave_space_submit">
<item quantity="one">"Forlad %1$d rum og klynge"</item>
<item quantity="other">"Forlad %1$d rum og klynger"</item>

View file

@ -1,6 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_leave_space_last_admin_info">"(Admin)"</string>
<plurals name="screen_leave_space_submit">
<item quantity="one">"%1$d Chat und Space verlassen"</item>
<item quantity="other">"%1$d Chats und Space verlassen"</item>

View file

@ -1,6 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_leave_space_last_admin_info">"(Admin)"</string>
<plurals name="screen_leave_space_submit">
<item quantity="one">"Quitter %1$d salon et lespace"</item>
<item quantity="other">"Quitter %1$d salons et lespace"</item>

View file

@ -1,6 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_leave_space_last_admin_info">"(Adminisztrátor)"</string>
<plurals name="screen_leave_space_submit">
<item quantity="one">"%1$d szoba és tér elhagyása"</item>
<item quantity="other">"%1$d szoba és tér elhagyása"</item>

View file

@ -1,11 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_leave_space_last_admin_info">"(Admin)"</string>
<string name="screen_leave_space_last_admin_info">"%1$s (Admin)"</string>
<plurals name="screen_leave_space_submit">
<item quantity="one">"Leave %1$d room and space"</item>
<item quantity="other">"Leave %1$d rooms and space"</item>
</plurals>
<string name="screen_leave_space_subtitle">"Select the rooms youd like to leave which you\'re not the only administrator for:"</string>
<string name="screen_leave_space_subtitle_last_admin">"You need to assign another admin for this space before you can leave."</string>
<string name="screen_leave_space_subtitle_only_last_admin">"You will not be removed from the following room(s) because you\'re the only administrator:"</string>
<string name="screen_leave_space_title">"Leave %1$s?"</string>
<string name="screen_leave_space_title_last_admin">"You are the only admin for %1$s"</string>
</resources>

View file

@ -5,60 +5,203 @@
* Please see LICENSE files in the repository root for full details.
*/
@file:OptIn(ExperimentalCoroutinesApi::class)
package io.element.android.features.space.impl.leave
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.spaces.SpaceRoomList
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.spaces.LeaveSpaceHandle
import io.element.android.libraries.matrix.api.spaces.LeaveSpaceRoom
import io.element.android.libraries.matrix.api.spaces.SpaceRoom
import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_ROOM_ID_2
import io.element.android.libraries.matrix.test.A_SPACE_ID
import io.element.android.libraries.matrix.test.A_SPACE_NAME
import io.element.android.libraries.matrix.test.spaces.FakeSpaceRoomList
import io.element.android.libraries.matrix.test.spaces.FakeLeaveSpaceHandle
import io.element.android.libraries.previewutils.room.aSpaceRoom
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.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.Test
class LeaveSpacePresenterTest {
private val aSpace = aSpaceRoom(
roomId = A_SPACE_ID,
name = A_SPACE_NAME,
)
@Test
fun `present - initial state`() = runTest {
val presenter = createLeaveSpacePresenter()
val presenter = createLeaveSpacePresenter(
leaveSpaceHandle = FakeLeaveSpaceHandle(
roomsResult = { Result.success(emptyList()) },
),
)
presenter.test {
val state = awaitItem()
assertThat(state.spaceName).isNull()
assertThat(state.selectableSpaceRooms).isEqualTo(AsyncData.Uninitialized)
assertThat(state.isLastAdmin).isFalse()
assertThat(state.selectableSpaceRooms.isLoading()).isTrue()
assertThat(state.leaveSpaceAction).isEqualTo(AsyncAction.Uninitialized)
skipItems(1)
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - current space name`() = runTest {
val fakeSpaceRoomList = FakeSpaceRoomList()
fun `present - fail to load rooms`() = runTest {
val presenter = createLeaveSpacePresenter(
spaceRoomList = fakeSpaceRoomList,
leaveSpaceHandle = FakeLeaveSpaceHandle(
roomsResult = { Result.failure(AN_EXCEPTION) },
)
)
presenter.test {
val state = awaitItem()
advanceUntilIdle()
assertThat(state.spaceName).isNull()
val aSpace = aSpaceRoom(
name = A_SPACE_NAME
)
fakeSpaceRoomList.emitCurrentSpace(aSpace)
assertThat(state.selectableSpaceRooms.isLoading()).isTrue()
assertThat(state.leaveSpaceAction).isEqualTo(AsyncAction.Uninitialized)
skipItems(3)
val stateError = awaitItem()
assertThat(stateError.selectableSpaceRooms.isFailure()).isTrue()
// Retry
stateError.eventSink(LeaveSpaceEvents.Retry)
skipItems(1)
assertThat(awaitItem().spaceName).isEqualTo(A_SPACE_NAME)
val stateLoadingAgain = awaitItem()
assertThat(stateLoadingAgain.selectableSpaceRooms.isLoading()).isTrue()
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - current space name and is last admin`() = runTest {
val presenter = createLeaveSpacePresenter(
leaveSpaceHandle = FakeLeaveSpaceHandle(
roomsResult = { Result.success(listOf(aLeaveSpaceRoom(spaceRoom = aSpace, isLastAdmin = true))) },
)
)
presenter.test {
val state = awaitItem()
assertThat(state.spaceName).isNull()
skipItems(3)
val finalState = awaitItem()
assertThat(finalState.spaceName).isEqualTo(A_SPACE_NAME)
assertThat(finalState.isLastAdmin).isTrue()
// The current state is not in the sub room list
assertThat(finalState.selectableSpaceRooms.dataOrNull()!!).isEmpty()
}
}
@Test
fun `present - leave space and sub rooms`() = runTest {
val leaveResult = lambdaRecorder<List<RoomId>, Result<Unit>> { Result.success(Unit) }
val presenter = createLeaveSpacePresenter(
leaveSpaceHandle = FakeLeaveSpaceHandle(
roomsResult = {
Result.success(
listOf(
LeaveSpaceRoom(aSpaceRoom(roomId = A_ROOM_ID), isLastAdmin = false),
LeaveSpaceRoom(aSpaceRoom(roomId = A_ROOM_ID_2), isLastAdmin = true),
)
)
},
leaveResult = leaveResult,
)
)
presenter.test {
skipItems(4)
val state = awaitItem()
assertThat(state.spaceName).isNull()
assertThat(state.isLastAdmin).isFalse()
val data = state.selectableSpaceRooms.dataOrNull()!!
assertThat(data.size).isEqualTo(2)
// Only one room is selectable as the user is the last admin in the other one
val room1 = data[0]
assertThat(room1.spaceRoom.roomId).isEqualTo(A_ROOM_ID)
assertThat(room1.isSelected).isTrue()
assertThat(room1.isLastAdmin).isFalse()
val room2 = data[1]
assertThat(room2.spaceRoom.roomId).isEqualTo(A_ROOM_ID_2)
assertThat(room2.isSelected).isFalse()
assertThat(room2.isLastAdmin).isTrue()
// Deselect all
state.eventSink(LeaveSpaceEvents.DeselectAllRooms)
skipItems(1)
val stateAllDeselected = awaitItem()
val dataAllDeselected = stateAllDeselected.selectableSpaceRooms.dataOrNull()!!
assertThat(dataAllDeselected.any { it.isSelected }).isFalse()
// Select all
stateAllDeselected.eventSink(LeaveSpaceEvents.SelectAllRooms)
skipItems(1)
val stateAllSelected = awaitItem()
val dataAllSelected = stateAllSelected.selectableSpaceRooms.dataOrNull()!!
// The last admin room should not be selected
assertThat(dataAllSelected.count { it.isSelected }).isEqualTo(1)
// Toggle selection of the first room
stateAllSelected.eventSink(LeaveSpaceEvents.ToggleRoomSelection(A_ROOM_ID))
skipItems(1)
val stateOneDeselected = awaitItem()
val dataOneDeselected = stateOneDeselected.selectableSpaceRooms.dataOrNull()!!
assertThat(dataOneDeselected[0].isSelected).isFalse()
// Toggle selection of the first room
stateOneDeselected.eventSink(LeaveSpaceEvents.ToggleRoomSelection(A_ROOM_ID))
skipItems(1)
val stateOneSelected = awaitItem()
val dataOneSelected = stateOneSelected.selectableSpaceRooms.dataOrNull()!!
assertThat(dataOneSelected[0].isSelected).isTrue()
// Leave space
stateOneSelected.eventSink(LeaveSpaceEvents.LeaveSpace)
val stateLeaving = awaitItem()
assertThat(stateLeaving.leaveSpaceAction).isEqualTo(AsyncAction.Loading)
val stateLeft = awaitItem()
assertThat(stateLeft.leaveSpaceAction.isSuccess()).isTrue()
leaveResult.assertions().isCalledOnce().with(
value(listOf(A_ROOM_ID))
)
}
}
@Test
fun `present - leave space error and close`() = runTest {
val leaveResult = lambdaRecorder<List<RoomId>, Result<Unit>> {
Result.failure(AN_EXCEPTION)
}
val presenter = createLeaveSpacePresenter(
leaveSpaceHandle = FakeLeaveSpaceHandle(
roomsResult = { Result.success(emptyList()) },
leaveResult = leaveResult,
)
)
presenter.test {
skipItems(4)
val state = awaitItem()
state.eventSink(LeaveSpaceEvents.LeaveSpace)
val stateLeaving = awaitItem()
assertThat(stateLeaving.leaveSpaceAction).isEqualTo(AsyncAction.Loading)
val stateError = awaitItem()
assertThat(stateError.leaveSpaceAction.isFailure()).isTrue()
// Close error
stateError.eventSink(LeaveSpaceEvents.CloseError)
val stateErrorClosed = awaitItem()
assertThat(stateErrorClosed.leaveSpaceAction).isEqualTo(AsyncAction.Uninitialized)
}
}
private fun createLeaveSpacePresenter(
spaceRoomList: SpaceRoomList = FakeSpaceRoomList(),
leaveSpaceHandle: LeaveSpaceHandle = FakeLeaveSpaceHandle(),
): LeaveSpacePresenter {
return LeaveSpacePresenter(
spaceRoomList = spaceRoomList,
leaveSpaceHandle = leaveSpaceHandle,
)
}
}
private fun aLeaveSpaceRoom(
spaceRoom: SpaceRoom = aSpaceRoom(
roomId = A_SPACE_ID,
name = A_SPACE_NAME,
),
isLastAdmin: Boolean = false,
) = LeaveSpaceRoom(
spaceRoom = spaceRoom,
isLastAdmin = isLastAdmin,
)

View file

@ -20,6 +20,7 @@ class LeaveSpaceStateTest {
selectableSpaceRooms = AsyncData.Loading()
)
assertThat(sut.showQuickAction).isFalse()
assertThat(sut.showLeaveButton).isFalse()
assertThat(sut.areAllSelected).isTrue()
assertThat(sut.hasOnlyLastAdminRoom).isFalse()
assertThat(sut.selectedRoomsCount).isEqualTo(0)
@ -33,11 +34,29 @@ class LeaveSpaceStateTest {
)
)
assertThat(sut.showQuickAction).isFalse()
assertThat(sut.showLeaveButton).isTrue()
assertThat(sut.areAllSelected).isTrue()
assertThat(sut.hasOnlyLastAdminRoom).isFalse()
assertThat(sut.selectedRoomsCount).isEqualTo(0)
}
@Test
fun `test last admin`() {
val sut = aLeaveSpaceState(
isLastAdmin = true,
selectableSpaceRooms = AsyncData.Success(
persistentListOf(
aSelectableSpaceRoom(isLastAdmin = false, isSelected = false),
)
)
)
assertThat(sut.showQuickAction).isFalse()
assertThat(sut.showLeaveButton).isFalse()
assertThat(sut.areAllSelected).isFalse()
assertThat(sut.hasOnlyLastAdminRoom).isFalse()
assertThat(sut.selectedRoomsCount).isEqualTo(0)
}
@Test
fun `test no last admin, 1 selected, 1 not selected`() {
val sut = aLeaveSpaceState(
@ -49,6 +68,7 @@ class LeaveSpaceStateTest {
)
)
assertThat(sut.showQuickAction).isTrue()
assertThat(sut.showLeaveButton).isTrue()
assertThat(sut.areAllSelected).isFalse()
assertThat(sut.hasOnlyLastAdminRoom).isFalse()
assertThat(sut.selectedRoomsCount).isEqualTo(1)
@ -65,6 +85,7 @@ class LeaveSpaceStateTest {
)
)
assertThat(sut.showQuickAction).isTrue()
assertThat(sut.showLeaveButton).isTrue()
assertThat(sut.areAllSelected).isTrue()
assertThat(sut.hasOnlyLastAdminRoom).isFalse()
assertThat(sut.selectedRoomsCount).isEqualTo(2)
@ -82,6 +103,7 @@ class LeaveSpaceStateTest {
)
)
assertThat(sut.showQuickAction).isTrue()
assertThat(sut.showLeaveButton).isTrue()
assertThat(sut.areAllSelected).isTrue()
assertThat(sut.hasOnlyLastAdminRoom).isFalse()
assertThat(sut.selectedRoomsCount).isEqualTo(2)
@ -98,6 +120,7 @@ class LeaveSpaceStateTest {
)
)
assertThat(sut.showQuickAction).isFalse()
assertThat(sut.showLeaveButton).isTrue()
assertThat(sut.areAllSelected).isTrue()
assertThat(sut.hasOnlyLastAdminRoom).isTrue()
assertThat(sut.selectedRoomsCount).isEqualTo(0)

View file

@ -166,7 +166,7 @@ test_detekt_test = { module = "io.gitlab.arturbosch.detekt:detekt-test", version
# https://github.com/matrix-org/matrix-rust-components-kotlin/commits/main/sdk/sdk-android/src/main/kotlin/org/matrix/rustcomponents/sdk/matrix_sdk_ffi.kt
# All new features should not be implemented in the pull request that upgrades the version, developers should
# only fix API breaks and may add some TODOs.
matrix_sdk = "org.matrix.rustcomponents:sdk-android:25.10.1"
matrix_sdk = "org.matrix.rustcomponents:sdk-android:25.10.2"
# Others
coil = { module = "io.coil-kt.coil3:coil", version.ref = "coil" }

View file

@ -69,14 +69,6 @@ object MatrixPatterns {
str matches PATTERN_CONTAIN_MATRIX_USER_IDENTIFIER
}
/**
* Tells if a string is a valid space id. This is an alias for [isRoomId]
*
* @param str the string to test
* @return true if the string is a valid space Id
*/
fun isSpaceId(str: String?) = isRoomId(str)
/**
* Tells if a string is a valid room id.
*

View file

@ -7,23 +7,7 @@
package io.element.android.libraries.matrix.api.core
import io.element.android.libraries.androidutils.metadata.isInDebug
import java.io.Serializable
@JvmInline
value class SpaceId(val value: String) : Serializable {
init {
if (isInDebug && !MatrixPatterns.isSpaceId(value)) {
error(
"`$value` is not a valid space id.\n" +
"Space ids are the same as room ids.\n" +
"Example space id: `!space_id:domain`."
)
}
}
override fun toString(): String = value
}
typealias SpaceId = RoomId
/**
* Value to use when no space is selected by the user.

View file

@ -11,6 +11,7 @@ import io.element.android.libraries.matrix.api.exception.ClientException
sealed class RecoveryException(message: String) : Exception(message) {
class SecretStorage(message: String) : RecoveryException(message)
class Import(message: String) : RecoveryException(message)
data object BackupExistsOnServer : RecoveryException("BackupExistsOnServer")
data class Client(val exception: ClientException) : RecoveryException(exception.message ?: "Unknown error")
}

View file

@ -0,0 +1,34 @@
/*
* Copyright 2025 New Vector 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.libraries.matrix.api.spaces
import io.element.android.libraries.matrix.api.core.RoomId
interface LeaveSpaceHandle {
/**
* The id of the space to leave.
*/
val id: RoomId
/**
* Get a list of rooms that can be left when leaving the space.
* It will include the current space and all the subspaces and rooms that the user has joined.
*/
suspend fun rooms(): Result<List<LeaveSpaceRoom>>
/**
* Leave the space and the given rooms.
* If [roomIds] is empty, only the space will be left.
*/
suspend fun leave(roomIds: List<RoomId>): Result<Unit>
/**
* Close the handle and free resources.
*/
fun close()
}

View file

@ -0,0 +1,13 @@
/*
* Copyright 2025 New Vector 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.libraries.matrix.api.spaces
data class LeaveSpaceRoom(
val spaceRoom: SpaceRoom,
val isLastAdmin: Boolean,
)

View file

@ -15,4 +15,6 @@ interface SpaceService {
suspend fun joinedSpaces(): Result<List<SpaceRoom>>
fun spaceRoomList(id: RoomId): SpaceRoomList
fun getLeaveSpaceHandle(spaceId: RoomId): LeaveSpaceHandle
}

View file

@ -147,6 +147,8 @@ class RustMatrixClient(
private val innerRoomListService = innerSyncService.roomListService()
private val innerSpaceService = innerClient.spaceService()
private val roomMembershipObserver = RoomMembershipObserver()
private val rustSyncService = RustSyncService(
inner = innerSyncService,
dispatcher = sessionDispatcher,
@ -189,6 +191,7 @@ class RustMatrixClient(
override val spaceService: SpaceService = RustSpaceService(
innerSpaceService = innerSpaceService,
roomMembershipObserver = roomMembershipObserver,
sessionCoroutineScope = sessionCoroutineScope,
sessionDispatcher = sessionDispatcher,
)
@ -200,7 +203,7 @@ class RustMatrixClient(
)
private val roomInfoMapper = RoomInfoMapper()
private val roomMembershipObserver = RoomMembershipObserver()
private val roomFactory = RustRoomFactory(
roomListService = roomListService,
innerRoomListService = innerRoomListService,

View file

@ -20,6 +20,9 @@ fun Throwable.mapRecoveryException(): RecoveryException {
message = errorMessage
)
is RustRecoveryException.BackupExistsOnServer -> RecoveryException.BackupExistsOnServer
is RustRecoveryException.Import -> RecoveryException.Import(
message = errorMessage
)
is RustRecoveryException.Client -> RecoveryException.Client(
source.mapClientException()
)

View file

@ -0,0 +1,68 @@
/*
* Copyright 2025 New Vector 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.libraries.matrix.impl.spaces
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.CurrentUserMembership
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
import io.element.android.libraries.matrix.api.spaces.LeaveSpaceHandle
import io.element.android.libraries.matrix.api.spaces.LeaveSpaceRoom
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.launch
import timber.log.Timber
import org.matrix.rustcomponents.sdk.LeaveSpaceHandle as RustLeaveSpaceHandle
class RustLeaveSpaceHandle(
override val id: RoomId,
private val spaceRoomMapper: SpaceRoomMapper,
private val roomMembershipObserver: RoomMembershipObserver,
sessionCoroutineScope: CoroutineScope,
private val innerProvider: suspend () -> RustLeaveSpaceHandle,
) : LeaveSpaceHandle {
private val inner = CompletableDeferred<RustLeaveSpaceHandle>()
init {
sessionCoroutineScope.launch {
inner.complete(innerProvider())
}
}
override suspend fun rooms(): Result<List<LeaveSpaceRoom>> = runCatchingExceptions {
inner.await().rooms().map { leaveSpaceRoom ->
LeaveSpaceRoom(
spaceRoom = spaceRoomMapper.map(leaveSpaceRoom.spaceRoom),
isLastAdmin = leaveSpaceRoom.isLastAdmin,
)
}
}
override suspend fun leave(roomIds: List<RoomId>): Result<Unit> = runCatchingExceptions {
// Ensure the space is included and is the last room to be left
val roomToLeave = roomIds - id + id
inner.await().leave(roomToLeave.map { it.value })
}.onSuccess {
roomMembershipObserver.notifyUserLeftRoom(
roomId = id,
isSpace = true,
membershipBeforeLeft = CurrentUserMembership.JOINED,
)
}
@OptIn(ExperimentalCoroutinesApi::class)
override fun close() {
Timber.d("Destroying LeaveSpaceHandle $id")
try {
inner.getCompleted().destroy()
} catch (_: Exception) {
// Ignore, we just want to make sure it's completed
}
}
}

View file

@ -10,6 +10,8 @@ package io.element.android.libraries.matrix.impl.spaces
import io.element.android.libraries.core.coroutine.childScope
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
import io.element.android.libraries.matrix.api.spaces.LeaveSpaceHandle
import io.element.android.libraries.matrix.api.spaces.SpaceRoom
import io.element.android.libraries.matrix.api.spaces.SpaceRoomList
import io.element.android.libraries.matrix.api.spaces.SpaceService
@ -37,6 +39,7 @@ class RustSpaceService(
private val innerSpaceService: ClientSpaceService,
private val sessionCoroutineScope: CoroutineScope,
private val sessionDispatcher: CoroutineDispatcher,
private val roomMembershipObserver: RoomMembershipObserver,
) : SpaceService {
private val spaceRoomMapper = SpaceRoomMapper()
override val spaceRoomsFlow = MutableSharedFlow<List<SpaceRoom>>(replay = 1, extraBufferCapacity = 1)
@ -64,6 +67,17 @@ class RustSpaceService(
)
}
override fun getLeaveSpaceHandle(spaceId: RoomId): LeaveSpaceHandle {
return RustLeaveSpaceHandle(
id = spaceId,
spaceRoomMapper = spaceRoomMapper,
roomMembershipObserver = roomMembershipObserver,
sessionCoroutineScope = sessionCoroutineScope,
) {
innerSpaceService.leaveSpace(spaceId.value)
}
}
init {
innerSpaceService
.spaceListUpdate()

View file

@ -0,0 +1,34 @@
/*
* Copyright 2025 New Vector 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.libraries.matrix.test.spaces
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.spaces.LeaveSpaceHandle
import io.element.android.libraries.matrix.api.spaces.LeaveSpaceRoom
import io.element.android.libraries.matrix.test.A_SPACE_ID
import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.simulateLongTask
class FakeLeaveSpaceHandle(
override val id: RoomId = A_SPACE_ID,
private val roomsResult: () -> Result<List<LeaveSpaceRoom>> = { lambdaError() },
private val leaveResult: (List<RoomId>) -> Result<Unit> = { lambdaError() },
private val closeResult: () -> Unit = { lambdaError() },
) : LeaveSpaceHandle {
override suspend fun rooms(): Result<List<LeaveSpaceRoom>> = simulateLongTask {
roomsResult()
}
override suspend fun leave(roomIds: List<RoomId>): Result<Unit> = simulateLongTask {
leaveResult(roomIds)
}
override fun close() {
return closeResult()
}
}

View file

@ -8,6 +8,7 @@
package io.element.android.libraries.matrix.test.spaces
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.spaces.LeaveSpaceHandle
import io.element.android.libraries.matrix.api.spaces.SpaceRoom
import io.element.android.libraries.matrix.api.spaces.SpaceRoomList
import io.element.android.libraries.matrix.api.spaces.SpaceService
@ -20,6 +21,7 @@ import kotlinx.coroutines.flow.asSharedFlow
class FakeSpaceService(
private val joinedSpacesResult: () -> Result<List<SpaceRoom>> = { lambdaError() },
private val spaceRoomListResult: (RoomId) -> SpaceRoomList = { lambdaError() },
private val leaveSpaceHandleResult: (RoomId) -> LeaveSpaceHandle = { lambdaError() },
) : SpaceService {
private val _spaceRoomsFlow = MutableSharedFlow<List<SpaceRoom>>()
override val spaceRoomsFlow: SharedFlow<List<SpaceRoom>>
@ -36,4 +38,8 @@ class FakeSpaceService(
override fun spaceRoomList(id: RoomId): SpaceRoomList {
return spaceRoomListResult(id)
}
override fun getLeaveSpaceHandle(spaceId: RoomId): LeaveSpaceHandle {
return leaveSpaceHandleResult(spaceId)
}
}

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:5219016a0af4e1e4f7703dcef3400bf030444845dc0ef52f084e69963170bf1e
size 13951
oid sha256:3c569a42e638a49554127d3dc59c1dda5306e50406a6eaf1033345b44e2fe037
size 13941

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:964aff2fc49b9d71ebec506bc9acc5010071efca01ed95f240a65b2c91f0f3b1
size 15852
oid sha256:b43c73a9da70133fdff8c63dd6ed8b130b76fbbc28977fae9a34733b0f64faea
size 15840

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:a2097486621a733b1662c02849ec71423d00f9c97c679996fafc8f20a4ef27ce
size 43844
oid sha256:02ea4a02c0901c3d31a86272659f251e3b9299f669177b7a46a9007159989519
size 44313

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:daff7333af912c8a0d7f009ba10d044aaa7cdfea2dfa3ccff2fd97209bf3278e
size 44225
oid sha256:0a8d851c9393b9a64642d08be96b0c449ce894fdf69d52d0eb664d83c7005520
size 44211

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:aa05867ac79a3bfb9f59bf1dafb02185274b46e9a5fbf27415a0e907780a0493
size 36393
oid sha256:78cc2ef488b13f72da4d1d149329930816e1d91541bbe62eb48c78b61564dc18
size 35868

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e74abc389c25fa9a5d33ade6d0f8a2242aeb5150fe81a26e26e9b561c46bb802
size 43010
oid sha256:426847168cf1cf81df60193ba69d1c02cad567f0a72c03f0b2dadb76e92f41c0
size 42600

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:b5c8bdf8d389cccb4cced29aa854a70860e9b44cbdde2a25ab95ccd5bc5e1674
size 39222
oid sha256:479fb84a857b3fcb15a9bba6a6fac3f8e1309a7d5af9c10c1cfe1d16c2eb678f
size 42208

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:bbf5d7c5320daa0d6b3c6678a6766c7143bc877850257e2c6923b174dfa9b6d4
size 34565
oid sha256:ba7631fe1e5d20946256829e8414f5a305e3a2755d79fc0693fd932bfac40f4a
size 40182

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:298db58a3f80f4194c5710ceeabec0e49b0e80861b931693d58b1a17f4394b4e
size 13873
oid sha256:6d5be43f0ae09dfea01efa28519b003c6c3bbd8a47c8329ccc9721acdc84a116
size 16531

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:520698bcbc65cd2ccbc73aff01132a6b5d39c7d349a814e5a26e584cec5f2eeb
size 26083

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:b982dc465fd47dbce6f88321f80c5390ecc90f7e8ad59a76dd44b6c2b9b80c8a
size 13923
oid sha256:1935a53ffe3653f9c094996a7b62359fe70c18ea0b46a7204969f70ce439b5e7
size 13924

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:00b13582a4a8a380cc502ca2bb181c1ee7c9f2e1685e1d2a75817107d5ebea99
size 15393
oid sha256:f90882b794619797d17c9bdb8ff2e56664a5bd95ab9bee0bb59371f954c0e7ca
size 15395

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:6ad6ae058adb3decb4733c74ad40e4efdd33744eea3e9a793e92fc6b4cbc1464
size 42784
oid sha256:4071cfff533916af1c99636ce6f61e2fbfd8d945dbba1b11e449ce55fd4bd7b9
size 43148

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ad30950d847bd769e19ce67d8944c16145e929c52210cd54b1ebc5966efe89b5
size 43221
oid sha256:03dd230a42399b5db39a57dbde170955c7f40793fced9420e2fcf8c55820b8c4
size 42948

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:6465f03be41ae02635cbdc94f521059bdd6476c5b2ccfca6f28ccd8898112cea
size 35530
oid sha256:f8d4a0d5f17cf6781d34af68ec0fae1c64cdf88d59ddf4306db411c4c67fcfd8
size 34945

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:af017745220027708f5a84ca639c9990fe7e474a38aaeecc3cba82011d195360
size 42080
oid sha256:26fcf6cfae843737df553b143d9cbb8049001548b0c3479e1e24a28e0c5e6527
size 41632

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:73f85bf9ec6c6682e293db2c740d206f6d895d79b3f3d30b967ad55aedbb268c
size 37895
oid sha256:bc8a9e1d6fd7a81cf1ef718d2f3e38babcf96e58e00c2c92827cba0aadd8903e
size 40613

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:49e2727c42d334c539ee14aab396cadd68b1290e6a256ec2f677f7e4801b30e9
size 32677
oid sha256:1f0ce9d4bbe5a8bdadee5663e8ad8b9b189aa25b5eec7e7c1a40430c09f7d1d3
size 38108

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:6ef494178398bc0f8e7721b7dfde669da4d723aeafa0d4ca6975a58215193416
size 13848
oid sha256:16d9c0ce7ffefb7825efb5e824c06bc721f85f3ff88340abf9c41b2b4679492c
size 16435

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:3ac66ddfcf3125726d2a9a7a83ef23880339f56c12bf2cad68bc969a4deb0f38
size 25776